@peopl-health/nexus 5.9.0-dev.921 → 6.0.0-dev.611

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.
Files changed (126) hide show
  1. package/lib/clinical/AssistantProcessor.js +5 -34
  2. package/lib/clinical/config/divergenceConfig.js +1 -18
  3. package/lib/clinical/config/llmConfig.js +1 -9
  4. package/lib/clinical/context/ToolRuntimeContext.js +2 -1
  5. package/lib/clinical/helpers/divergenceHelper.js +0 -12
  6. package/lib/clinical/index.js +0 -2
  7. package/lib/clinical/memory/DefaultMemoryManager.js +9 -19
  8. package/lib/clinical/models/turnTraceModel.js +5 -18
  9. package/lib/clinical/providers/AnthropicProvider.js +29 -291
  10. package/lib/clinical/providers/BaseLLMProvider.js +24 -113
  11. package/lib/clinical/providers/GatewayProvider.js +3 -3
  12. package/lib/clinical/providers/OpenAIResponsesProvider.js +4 -46
  13. package/lib/clinical/services/divergenceService.js +5 -22
  14. package/lib/clinical/services/promptComposerService.js +15 -46
  15. package/lib/clinical/services/shadowMessageService.js +12 -28
  16. package/lib/clinical/services/shadowService.js +3 -12
  17. package/lib/clinical/stores/CtcaeCatalog.js +0 -20
  18. package/lib/config/airtableConfig.js +0 -3
  19. package/lib/core/BatchingManager.js +3 -10
  20. package/lib/core/NexusMessaging.js +13 -31
  21. package/lib/eval/EvalProvider.js +2 -3
  22. package/lib/fhir/config/fhirConfig.js +3 -34
  23. package/lib/fhir/constants/projectionSlugs.js +1 -41
  24. package/lib/fhir/helpers/elementHelper.js +0 -21
  25. package/lib/fhir/helpers/fhirHelper.js +2 -5
  26. package/lib/fhir/index.js +0 -24
  27. package/lib/fhir/models/envelopeModel.js +0 -5
  28. package/lib/fhir/projections/registerProjectors.js +0 -16
  29. package/lib/fhir/resources/Condition.js +2 -107
  30. package/lib/fhir/resources/Observation.js +1 -73
  31. package/lib/fhir/resources/Procedure.js +2 -34
  32. package/lib/fhir/resources/Provenance.js +2 -93
  33. package/lib/fhir/services/fhirService.js +2 -2
  34. package/lib/helpers/twilioHelper.js +1 -9
  35. package/lib/index.d.ts +0 -16
  36. package/lib/index.js +0 -9
  37. package/lib/models/messageModel.js +0 -6
  38. package/lib/routes/index.js +1 -21
  39. package/lib/services/airtableService.js +1 -2
  40. package/lib/shared/dtos/ContingencySafetyNet.js +3 -10
  41. package/lib/shared/dtos/DispatchedEscalation.js +8 -3
  42. package/lib/shared/dtos/Reminder.js +2 -2
  43. package/lib/storage/MongoStorage.js +0 -2
  44. package/package.json +1 -1
  45. package/lib/clinical/config/cohortConfig.js +0 -68
  46. package/lib/clinical/config/composerConfig.js +0 -42
  47. package/lib/clinical/config/subAgentsConfig.js +0 -44
  48. package/lib/clinical/flags/bridgeConfig.js +0 -14
  49. package/lib/clinical/flags/composerFlags.js +0 -14
  50. package/lib/clinical/helpers/clinicalFactsHelper.js +0 -108
  51. package/lib/clinical/helpers/clinicalMentionHelper.js +0 -68
  52. package/lib/clinical/helpers/cohortHelper.js +0 -22
  53. package/lib/clinical/helpers/gradeEstimateHelper.js +0 -29
  54. package/lib/clinical/helpers/resultsHelper.js +0 -34
  55. package/lib/clinical/helpers/safetyFlagsHelper.js +0 -10
  56. package/lib/clinical/services/bridgeService.js +0 -55
  57. package/lib/clinical/services/clinicalAirtableService.js +0 -162
  58. package/lib/clinical/services/clinicalExtractionService.js +0 -112
  59. package/lib/clinical/services/composerService.js +0 -59
  60. package/lib/clinical/services/dispatcherService.js +0 -137
  61. package/lib/clinical/services/reminderDispatchService.js +0 -36
  62. package/lib/clinical/services/skillService.js +0 -44
  63. package/lib/clinical/tools/analyzeSymptomPatternsTool.js +0 -279
  64. package/lib/clinical/tools/deliverPatientMessageTool.js +0 -202
  65. package/lib/clinical/tools/extractClinicalInfoTool.js +0 -167
  66. package/lib/clinical/tools/getActiveSymptomLandscapeTool.js +0 -141
  67. package/lib/clinical/tools/getPatientHistoryTool.js +0 -235
  68. package/lib/clinical/tools/getPatientRiskProfileTool.js +0 -71
  69. package/lib/clinical/tools/getRouterContextBundleTool.js +0 -93
  70. package/lib/clinical/tools/lookupCtcaeEntryTool.js +0 -35
  71. package/lib/clinical/tools/openClusterHypothesisTool.js +0 -160
  72. package/lib/clinical/tools/openConditionTool.js +0 -136
  73. package/lib/clinical/tools/recordClinicalImpressionTool.js +0 -251
  74. package/lib/clinical/tools/recordInterventionTool.js +0 -140
  75. package/lib/clinical/tools/registerClinicalTools.js +0 -39
  76. package/lib/clinical/tools/reportCrisisTool.js +0 -211
  77. package/lib/clinical/tools/reportMedicalEscalationTool.js +0 -187
  78. package/lib/clinical/tools/reportResultsReceivedTool.js +0 -153
  79. package/lib/clinical/tools/reportUnresolvedRequestv2Tool.js +0 -177
  80. package/lib/clinical/tools/schedulePatientReminderTool.js +0 -149
  81. package/lib/clinical/tools/setContingencyPlanTool.js +0 -176
  82. package/lib/clinical/tools/skillTool.js +0 -67
  83. package/lib/clinical/tools/submitRoutingDecisionTool.js +0 -291
  84. package/lib/clinical/tools/updateClusterHypothesisTool.js +0 -159
  85. package/lib/controllers/cohortController.js +0 -16
  86. package/lib/controllers/traceController.js +0 -26
  87. package/lib/core/workflowRunner.js +0 -35
  88. package/lib/fhir/config/fhirTablesConfig.js +0 -9
  89. package/lib/fhir/constants/severityPriority.js +0 -8
  90. package/lib/fhir/helpers/compositeHelper.js +0 -88
  91. package/lib/fhir/helpers/fhirReadHelper.js +0 -42
  92. package/lib/fhir/helpers/terminologyHelper.js +0 -260
  93. package/lib/fhir/projections/clusterProjection.js +0 -24
  94. package/lib/fhir/projections/contingencyProjection.js +0 -36
  95. package/lib/fhir/projections/dispatchedEscalationProjection.js +0 -25
  96. package/lib/fhir/projections/interventionProjection.js +0 -42
  97. package/lib/fhir/projections/proctcaeProjection.js +0 -179
  98. package/lib/fhir/projections/reminderProjection.js +0 -20
  99. package/lib/fhir/projections/riskProjection.js +0 -16
  100. package/lib/fhir/projections/routingProjection.js +0 -48
  101. package/lib/fhir/projections/symptomCaseProjection.js +0 -65
  102. package/lib/fhir/resources/CarePlan.js +0 -93
  103. package/lib/fhir/resources/ClinicalImpression.js +0 -118
  104. package/lib/fhir/resources/Communication.js +0 -65
  105. package/lib/fhir/resources/CommunicationRequest.js +0 -182
  106. package/lib/fhir/resources/Device.js +0 -24
  107. package/lib/fhir/resources/Patient.js +0 -18
  108. package/lib/fhir/resources/Questionnaire.js +0 -23
  109. package/lib/fhir/resources/QuestionnaireResponse.js +0 -29
  110. package/lib/fhir/resources/RequestOrchestration.js +0 -46
  111. package/lib/fhir/resources/RiskAssessment.js +0 -99
  112. package/lib/fhir/resources/Task.js +0 -106
  113. package/lib/fhir/resources/interventionExtensions.js +0 -28
  114. package/lib/fhir/services/clusterService.js +0 -77
  115. package/lib/fhir/services/contingencyService.js +0 -117
  116. package/lib/fhir/services/interventionService.js +0 -149
  117. package/lib/fhir/services/resultObservationService.js +0 -52
  118. package/lib/fhir/services/riskService.js +0 -101
  119. package/lib/fhir/services/routingService.js +0 -147
  120. package/lib/fhir/services/snapshotService.js +0 -28
  121. package/lib/fhir/services/symptomCaseService.js +0 -139
  122. package/lib/fhir/services/terminologyService.js +0 -58
  123. package/lib/fhir/services/triageService.js +0 -57
  124. package/lib/fhir/utils/normalizeUtils.js +0 -33
  125. package/lib/shared/dtos/GradedSymptom.js +0 -23
  126. package/lib/shared/dtos/TriageResponse.js +0 -35
@@ -1,60 +1,28 @@
1
1
  const { Anthropic } = require('@anthropic-ai/sdk');
2
2
 
3
- const { composePrompt } = require('../services/promptComposerService');
4
3
  const { retryWithBackoff } = require('../../utils/retryUtils');
5
4
  const { withTiming } = require('../../utils/tracingDecorator');
6
- const { isPlainObject, safeParse } = require('../../utils/jsonUtils');
7
5
 
8
6
  const { logger } = require('../../utils/logger');
9
7
 
10
- const runtimeConfig = require('../../config/runtimeConfig');
11
-
12
- const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS } = require('./BaseLLMProvider');
8
+ const { BaseLLMProvider } = require('./BaseLLMProvider');
13
9
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
14
10
 
15
- const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
16
-
17
- function resolveMaxFunctionRounds(explicit) {
18
- if (Number.isInteger(explicit) && explicit > 0) return explicit;
19
- const raw = runtimeConfig.get('MAX_FUNCTION_ROUNDS');
20
- const n = parseInt(raw, 10);
21
- if (n > 0) return n;
22
- if (raw !== undefined) {
23
- logger.warn('[AnthropicProvider] Invalid MAX_FUNCTION_ROUNDS, falling back to default', { value: raw, default: DEFAULT_MAX_FUNCTION_ROUNDS });
24
- }
25
- return DEFAULT_MAX_FUNCTION_ROUNDS;
26
- }
27
-
28
- // Iterations that don't count against the tool budget: terminal-only delivery rounds (exempt at the toolRounds check) plus the loop's own end/break passes. Bounds the loop against a misbehaving model.
29
- const LOOP_ITERATION_HEADROOM = 3;
30
- const CACHE_TTLS = ['5m', '1h'];
31
- const DEFAULT_CACHE_TTL = '1h';
32
- const DEFAULT_THINKING_MODE = 'until_commit';
33
- // Turns that end the model's clinical reasoning for the turn; after one, until_commit stops thinking. Diverges from the oracle,
34
- // which excludes submitRoutingDecision because its palliative/emergency branches do more work post-routing. Nexus has no careplan
35
- // or palliative tools (registerClinicalTools.js) — submitRoutingDecision is the terminal routing checkpoint here, so it belongs.
36
- const TERMINAL_COMMIT_TOOLS = new Set(['submitRoutingDecision', 'reportMedicalEscalation', 'reportCrisis', 'reportUnresolvedRequestv2']);
11
+ const DEFAULT_MAX_FUNCTION_ROUNDS = 5;
37
12
 
38
13
  class AnthropicProvider extends BaseLLMProvider {
39
14
  constructor(options = {}) {
40
15
  super(options);
41
- const { apiKey, client, maxFunctionRounds, defaultModels = {} } = options;
16
+ const { apiKey, client, maxFunctionRounds = DEFAULT_MAX_FUNCTION_ROUNDS, defaultModels = {} } = options;
42
17
 
43
18
  if (!client && !apiKey) {
44
19
  throw new Error('AnthropicProvider requires an API key or a preconfigured client');
45
20
  }
46
21
 
47
22
  this.client = client || new Anthropic({ apiKey });
48
- this.maxFunctionRounds = resolveMaxFunctionRounds(maxFunctionRounds);
49
- const cacheTtl = runtimeConfig.get('ANTHROPIC_CACHE_TTL', DEFAULT_CACHE_TTL);
50
- if (!CACHE_TTLS.includes(cacheTtl)) {
51
- logger.warn('[AnthropicProvider] Invalid ANTHROPIC_CACHE_TTL, falling back to default', { cacheTtl, default: DEFAULT_CACHE_TTL });
52
- }
53
- this.cacheTtl = CACHE_TTLS.includes(cacheTtl) ? cacheTtl : DEFAULT_CACHE_TTL;
23
+ this.maxFunctionRounds = maxFunctionRounds;
54
24
  this.defaults = {
55
25
  maxOutputTokens: 400,
56
- structuredModel: 'claude-haiku-4-5',
57
- structuredMaxTokens: 2048,
58
26
  ...defaultModels,
59
27
  };
60
28
 
@@ -64,7 +32,7 @@ class AnthropicProvider extends BaseLLMProvider {
64
32
 
65
33
  async _invokeModel({
66
34
  instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
67
- modelConfig, assistant, phiProcessor = null, trace = null, onFirstIteration = null
35
+ modelConfig, assistant, phiProcessor = null, trace = null
68
36
  }, options = {}) {
69
37
  const executeTools = options.toolExecutor || handleFunctionCalls;
70
38
  let totalRetries = 0;
@@ -73,48 +41,23 @@ class AnthropicProvider extends BaseLLMProvider {
73
41
  const accumulatedUsage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
74
42
  const addUsage = (usage) => {
75
43
  if (!usage) return;
76
- const inputTokens = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
77
- accumulatedUsage.input_tokens += inputTokens;
44
+ accumulatedUsage.input_tokens += usage.input_tokens || 0;
78
45
  accumulatedUsage.output_tokens += usage.output_tokens || 0;
79
- accumulatedUsage.total_tokens += inputTokens + (usage.output_tokens || 0);
46
+ accumulatedUsage.total_tokens += (usage.input_tokens || 0) + (usage.output_tokens || 0);
80
47
  };
81
48
 
82
49
  const { system, messages } = this._toAnthropicMessages(instructions, input);
83
- const apiCallConfig = { ...this._mapModelConfig(modelConfig) };
84
- // Captured before prepThinking() starts toggling apiCallConfig.thinking per call, so both the per-iteration gate and the
85
- // tool_choice coercion read the same preset value rather than whichever iteration's state happens to be current.
86
- const baseThinking = apiCallConfig.thinking;
87
- const thinkingEnabled = baseThinking?.type === 'enabled';
88
- const initialChoice = this._mapToolChoice(toolChoice);
89
- // Only a request that actually carried the forced choice was coerced; with zero tools tool_choice was never sent.
90
- const toolChoiceCoerced = toolSchemas.length > 0 && thinkingEnabled && (initialChoice.type === 'tool' || initialChoice.type === 'any');
91
- if (system) apiCallConfig.system = system;
50
+ const apiCallConfig = { ...this._mapModelConfig(modelConfig), system };
92
51
  if (toolSchemas.length > 0) {
93
52
  apiCallConfig.tools = this._mapTools(toolSchemas);
94
- apiCallConfig.tool_choice = this._resolveToolChoice(toolChoice, thinkingEnabled);
53
+ apiCallConfig.tool_choice = this._mapToolChoice(toolChoice);
95
54
  }
96
55
 
97
- const thinkingMode = modelConfig?.thinking_mode || DEFAULT_THINKING_MODE;
98
- let modelCallNum = 0;
99
- let terminalCommitSeen = false;
100
- const prepThinking = () => {
101
- modelCallNum += 1;
102
- if (baseThinking && this._thinkingForIter(modelCallNum, thinkingMode, terminalCommitSeen)) apiCallConfig.thinking = baseThinking;
103
- else delete apiCallConfig.thinking;
104
- };
105
-
106
- const betas = Array.isArray(modelConfig?.betas) ? modelConfig.betas.filter(b => typeof b === 'string' && b.length) : [];
107
- const makeAPICall = (msgs) => {
108
- // interleaved-thinking requires thinking enabled on the call; drop it on iters the gate turned thinking off.
109
- const active = apiCallConfig.thinking ? betas : betas.filter(b => !b.startsWith('interleaved-thinking'));
110
- const requestOptions = active.length ? { headers: { 'anthropic-beta': active.join(',') } } : undefined;
111
- return retryWithBackoff(
112
- () => this.client.messages.create({ messages: msgs, ...apiCallConfig }, requestOptions),
113
- { providerName: this.constructor.name }
114
- );
115
- };
56
+ const makeAPICall = (msgs) => retryWithBackoff(
57
+ () => this.client.messages.create({ messages: msgs, ...apiCallConfig }),
58
+ { providerName: this.constructor.name }
59
+ );
116
60
 
117
- prepThinking();
118
61
  const { result: { result: response, retries }, durationMs } = await withTiming(() => makeAPICall(messages));
119
62
  totalRetries += retries;
120
63
  addUsage(response.usage);
@@ -122,78 +65,12 @@ class AnthropicProvider extends BaseLLMProvider {
122
65
  let finalResponse = response;
123
66
  const currentMessages = [...messages];
124
67
 
125
- // Coercion is silent-by-default; the event that matters is the model then *declining* the forced tool under 'auto'.
126
- if (toolChoiceCoerced) {
127
- const toolUses = (response.content || []).filter(block => block.type === 'tool_use');
128
- // A named force ('tool') is satisfied only by THAT tool; 'any'/'required' by any tool_use. Partial compliance
129
- // (e.g. DeliverPatientMessage fires but the forced reportUnresolvedRequestv2 does not) must count as skipped.
130
- const firedTool = initialChoice.type === 'tool'
131
- ? toolUses.some(block => block.name === initialChoice.name)
132
- : toolUses.length > 0;
133
- trace?.setSignals?.({ toolChoiceCoerced: true, forcedToolSkipped: !firedTool });
134
- if (!firedTool) {
135
- logger.warn('[AnthropicProvider] Forced tool skipped after coercion to auto under thinking', { requested: initialChoice.type, tool: initialChoice.name || null });
136
- }
137
- }
138
-
139
- let bridgeNote = null;
140
- if (onFirstIteration) {
141
- const narrationText = (response.content || [])
142
- .filter(block => block.type === 'text')
143
- .map(block => block.text || '')
144
- .join(' ')
145
- .trim();
146
- const hasToolCalls = Boolean(assistant) && toolSchemas.length > 0
147
- && response.stop_reason === 'tool_use'
148
- && (response.content || []).some(block => block.type === 'tool_use');
149
- try {
150
- const decision = onFirstIteration({ narrationText, hasToolCalls });
151
- if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
152
- } catch (err) {
153
- logger.warn('[AnthropicProvider] bridge callback failed', { error: err.message });
154
- }
155
- }
156
-
157
68
  if (assistant && toolSchemas.length > 0) {
158
- // Defensive: followUpToolChoice is the run-level value (already 'auto'/'none' on the classifier path); a forced value only
159
- // reaches here from a caller that sets one at run level (e.g. EvalProvider).
160
- apiCallConfig.tool_choice = this._resolveToolChoice(followUpToolChoice, thinkingEnabled);
161
- const canDeliver = toolSchemas.some(s => TERMINAL_DELIVERY_TOOLS.has(s.function?.name));
162
-
163
- let toolRounds = 0;
164
- let deliveryNudges = 0;
165
- const maxIterations = this.maxFunctionRounds + MAX_DELIVERY_NUDGES + LOOP_ITERATION_HEADROOM;
166
- for (let iteration = 1; iteration <= maxIterations; iteration++) {
167
- const content = finalResponse.content || [];
168
- const toolUses = content.filter(block => block.type === 'tool_use');
169
- const ended = finalResponse.stop_reason !== 'tool_use' || !toolUses.length;
170
- const terminalOnly = toolUses.length > 0 && toolUses.every(block => TERMINAL_DELIVERY_TOOLS.has(block.name));
171
- const budgetSpent = toolRounds >= this.maxFunctionRounds && !terminalOnly;
172
-
173
- // Nudge on an actual delivery, not narration: a text block is not a patient message, so a turn ending in
174
- // narration without DeliverPatientMessage still nudges. Rides on the last user turn (empty completions carry no content to echo).
175
- if ((ended || budgetSpent) && canDeliver && !this._deliveryCommitted(allToolsExecuted) && deliveryNudges < MAX_DELIVERY_NUDGES) {
176
- const lastUserTurn = currentMessages[currentMessages.length - 1];
177
- if (lastUserTurn?.role === 'user') {
178
- deliveryNudges++;
179
- logger.warn('[AnthropicProvider] Turn will not deliver — nudging DeliverPatientMessage', { deliveryNudges, ended, budgetSpent });
180
- const turnContent = Array.isArray(lastUserTurn.content)
181
- ? lastUserTurn.content
182
- : [{ type: 'text', text: String(lastUserTurn.content ?? '') }];
183
- lastUserTurn.content = [...turnContent, { type: 'text', text: DELIVERY_NUDGE }];
184
- // The nudge is a model call like any other: gate thinking on it so the per-iteration counter stays honest.
185
- prepThinking();
186
- const { result: { result: nudged, retries: nudgeRetries }, durationMs: nudgeDurationMs } = await withTiming(() => makeAPICall(currentMessages));
187
- totalRetries += nudgeRetries;
188
- addUsage(nudged.usage);
189
- producingIteration = trace?.addIteration({ response: nudged, durationMs: nudgeDurationMs, apiCallConfig }) || null;
190
- finalResponse = nudged;
191
- continue;
192
- }
193
- }
69
+ apiCallConfig.tool_choice = this._mapToolChoice(followUpToolChoice);
194
70
 
195
- if (ended || budgetSpent) break;
196
- if (!terminalOnly) toolRounds++;
71
+ for (let round = 1; round <= this.maxFunctionRounds; round++) {
72
+ const toolUses = (finalResponse.content || []).filter(block => block.type === 'tool_use');
73
+ if (finalResponse.stop_reason !== 'tool_use' || !toolUses.length) break;
197
74
 
198
75
  const functionCalls = toolUses.map(block => ({
199
76
  name: block.name,
@@ -203,150 +80,43 @@ class AnthropicProvider extends BaseLLMProvider {
203
80
  allToolCalls.push(...toolUses.map(block => ({ name: block.name, arguments: block.input ?? {}, call_id: block.id })));
204
81
  const { outputs, toolsExecuted } = await executeTools(functionCalls, assistant, phiProcessor, producingIteration);
205
82
 
206
- const userContent = outputs.map(o => ({ type: 'tool_result', tool_use_id: o.call_id, content: o.output }));
207
- if (bridgeNote) {
208
- userContent.push({ type: 'text', text: bridgeNote });
209
- bridgeNote = null;
210
- }
211
83
  currentMessages.push(
212
84
  { role: 'assistant', content: finalResponse.content },
213
- { role: 'user', content: userContent }
85
+ { role: 'user', content: outputs.map(o => ({ type: 'tool_result', tool_use_id: o.call_id, content: o.output })) }
214
86
  );
215
87
  allToolsExecuted.push(...toolsExecuted);
216
- if (toolsExecuted.some(t => TERMINAL_COMMIT_TOOLS.has(t?.tool_name))) terminalCommitSeen = true;
217
88
 
218
- prepThinking();
219
89
  const { result: { result: followUp, retries: followUpRetries }, durationMs: followUpDurationMs } = await withTiming(() => makeAPICall(currentMessages));
220
90
  totalRetries += followUpRetries;
221
91
  addUsage(followUp.usage);
222
92
  producingIteration = trace?.addIteration({ response: followUp, durationMs: followUpDurationMs, apiCallConfig }) || null;
223
93
  finalResponse = followUp;
224
94
  }
225
-
226
- // Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it visible.
227
- const pendingTools = (finalResponse.content || []).filter(block => block.type === 'tool_use');
228
- if (finalResponse.stop_reason === 'tool_use' && pendingTools.length) {
229
- logger.warn('[AnthropicProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingTools.map(b => b.name) });
230
- trace?.setSignals?.({ toolBudgetExhausted: true });
231
- }
232
95
  }
233
96
 
234
97
  return { response: this._normalizeResponse(finalResponse), toolCalls: allToolCalls, toolsExecuted: allToolsExecuted, retries: totalRetries, usage: accumulatedUsage };
235
98
  }
236
99
 
237
- async runStructured({ promptId = null, presetId = null, instructions = null, variables = null, input = [], text = null, model = null } = {}) {
238
- let modelConfig = {};
239
- let resolvedInstructions = instructions;
240
- if (promptId || presetId) {
241
- const composed = await composePrompt({ promptId, presetId, variables });
242
- modelConfig = composed.modelConfig || {};
243
- if (!resolvedInstructions) resolvedInstructions = composed.resolvedPrompt;
244
- }
245
-
246
- const { system, messages } = this._toAnthropicMessages(resolvedInstructions, input);
247
-
248
- const apiConfig = {
249
- model: model || (modelConfig.model && modelConfig.model.replace(/^anthropic\//, '')) || this.defaults.structuredModel,
250
- max_tokens: modelConfig.max_output_tokens || this.defaults.structuredMaxTokens,
251
- };
252
- if (system) apiConfig.system = system;
253
-
254
- const spec = text || modelConfig.text;
255
- let tool = null;
256
- if (spec) {
257
- const format = spec.format ? spec.format : spec;
258
- if (!format.schema) throw new Error('runStructured: format spec has no schema');
259
- tool = { name: format.name || 'structuredOutput', input_schema: format.schema };
260
- if (format.strict !== undefined) tool.strict = format.strict;
261
- apiConfig.tools = [tool];
262
- apiConfig.tool_choice = { type: 'tool', name: tool.name };
263
- }
264
-
265
- const { result } = await retryWithBackoff(
266
- () => this.client.messages.create({ messages, ...apiConfig }),
267
- { providerName: this.constructor.name },
268
- );
269
- return tool ? this._normalizeStructured(result, tool) : this._normalizeResponse(result);
270
- }
271
-
272
100
  _toAnthropicMessages(instructions, input) {
273
- // Caching is a prefix match, so one block would let any per-turn byte invalidate the prompt and
274
- // the tool schemas ahead of it. Split them: stable content is cached, runtime content is not.
275
- const stableParts = [];
276
- if (instructions) stableParts.push(instructions);
277
- const runtimeParts = [];
278
-
279
- const outputsByCallId = new Map();
280
- for (const item of input || []) {
281
- if (item.type === 'function_call_output') outputsByCallId.set(item.call_id, item.output);
282
- }
101
+ const systemParts = [];
102
+ if (instructions) systemParts.push(instructions);
283
103
 
284
104
  const messages = [];
285
- let pendingUses = [];
286
- let pendingResults = [];
287
- const flushTools = () => {
288
- if (!pendingUses.length) return;
289
- messages.push({ role: 'assistant', content: pendingUses });
290
- messages.push({ role: 'user', content: pendingResults });
291
- pendingUses = [];
292
- pendingResults = [];
293
- };
294
-
295
105
  for (const item of input || []) {
296
106
  const type = item.type || 'message';
297
- if (type === 'function_call') {
298
- if (!outputsByCallId.has(item.call_id)) continue;
299
- pendingUses.push({ type: 'tool_use', id: item.call_id, name: item.name, input: this._toToolInput(item.arguments) });
300
- pendingResults.push({ type: 'tool_result', tool_use_id: item.call_id, content: this._toToolContent(outputsByCallId.get(item.call_id)) });
107
+ if (type !== 'message') {
108
+ logger.debug('[AnthropicProvider] Skipping non-message input item', { type });
301
109
  continue;
302
110
  }
303
- if (type === 'function_call_output') continue;
304
- flushTools();
305
111
  const content = item.content;
306
- // The role picks the side: 'system' is stable (composerService ships skill prompts this way),
307
- // 'developer' is per-turn.
308
- if (item.role === 'system') {
309
- if (content) stableParts.push(content);
310
- continue;
311
- }
312
- if (item.role === 'developer') {
313
- if (content) runtimeParts.push(content);
112
+ if (item.role === 'developer' || item.role === 'system') {
113
+ if (content) systemParts.push(content);
314
114
  continue;
315
115
  }
316
116
  messages.push({ role: item.role === 'assistant' ? 'assistant' : 'user', content });
317
117
  }
318
- flushTools();
319
-
320
- if (!messages.length || messages[messages.length - 1].role === 'assistant') {
321
- messages.push({ role: 'user', content: CONVERSATION_CONTINUATION });
322
- }
323
-
324
- const system = [];
325
- if (stableParts.length) {
326
- system.push({
327
- type: 'text',
328
- text: stableParts.join('\n\n'),
329
- cache_control: { type: 'ephemeral', ttl: this.cacheTtl },
330
- });
331
- }
332
- // Deliberately no cache_control: caching a prefix that changes every turn only pays write costs.
333
- if (runtimeParts.length) {
334
- system.push({ type: 'text', text: runtimeParts.join('\n\n') });
335
- }
336
118
 
337
- return { system: system.length ? system : null, messages };
338
- }
339
-
340
- _toToolInput(args) {
341
- if (isPlainObject(args)) return args;
342
- const parsed = safeParse(args, null);
343
- if (isPlainObject(parsed)) return parsed;
344
- logger.warn('[AnthropicProvider] Unusable tool arguments in history, sending empty input');
345
- return {};
346
- }
347
-
348
- _toToolContent(output) {
349
- return typeof output === 'string' ? output : JSON.stringify(output ?? '');
119
+ return { system: systemParts.join('\n\n'), messages };
350
120
  }
351
121
 
352
122
  _mapModelConfig(modelConfig) {
@@ -358,8 +128,6 @@ class AnthropicProvider extends BaseLLMProvider {
358
128
  delete mapped.text;
359
129
  delete mapped.reasoning;
360
130
  delete mapped.max_output_tokens;
361
- delete mapped.betas;
362
- delete mapped.thinking_mode;
363
131
  if (mapped.max_tokens == null) mapped.max_tokens = this.defaults.maxOutputTokens;
364
132
  return mapped;
365
133
  }
@@ -367,35 +135,16 @@ class AnthropicProvider extends BaseLLMProvider {
367
135
  _mapTools(toolSchemas) {
368
136
  return toolSchemas.map(schema => {
369
137
  const fn = schema.function || schema;
370
- // The clinical tool set compiles as one grammar per request; strict on all of them
371
- // exceeds Anthropic's complexity limit. runStructured keeps strict — single schema.
372
- return {
138
+ const tool = {
373
139
  name: fn.name,
374
140
  description: fn.description,
375
141
  input_schema: fn.parameters || { type: 'object', properties: {} },
376
142
  };
143
+ if (fn.strict !== undefined) tool.strict = fn.strict;
144
+ return tool;
377
145
  });
378
146
  }
379
147
 
380
- _thinkingForIter(iterationNum, mode, terminalCommitSeen) {
381
- if (mode === 'always') return true;
382
- if (mode === 'first_iter') return iterationNum === 1;
383
- return iterationNum === 1 || !terminalCommitSeen;
384
- }
385
-
386
- // Anthropic rejects a forced tool_choice ('tool'/'any') while extended thinking is enabled, so under thinking a forced
387
- // choice is coerced to 'auto'. This deliberately prefers thinking over the tool guarantee: the model is already strongly
388
- // prompted to call the tool. Adaptive thinking ({type:'adaptive'}) is intentionally NOT coerced — it is accepted with a
389
- // forced choice on the first-party API (only Bedrock requires thinking off), so the guard fires on 'enabled' only.
390
- _resolveToolChoice(toolChoice, thinkingEnabled) {
391
- const mapped = this._mapToolChoice(toolChoice);
392
- if (thinkingEnabled && (mapped.type === 'tool' || mapped.type === 'any')) {
393
- logger.debug('[AnthropicProvider] Forced tool_choice coerced to auto under extended thinking', { requested: mapped.type, tool: mapped.name || null });
394
- return { type: 'auto' };
395
- }
396
- return mapped;
397
- }
398
-
399
148
  _mapToolChoice(toolChoice) {
400
149
  if (!toolChoice || toolChoice === 'auto') return { type: 'auto' };
401
150
  if (toolChoice === 'none') return { type: 'none' };
@@ -411,16 +160,6 @@ class AnthropicProvider extends BaseLLMProvider {
411
160
  .map(block => block.text || '')
412
161
  .join('')
413
162
  .trim();
414
- return this._envelope(response, outputText);
415
- }
416
-
417
- _normalizeStructured(response, tool) {
418
- const toolUse = (response.content || []).find(block => block.type === 'tool_use' && block.name === tool.name);
419
- const outputText = toolUse ? JSON.stringify(toolUse.input ?? {}) : '';
420
- return this._envelope(response, outputText);
421
- }
422
-
423
- _envelope(response, outputText) {
424
163
  const output = outputText
425
164
  ? [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: outputText }] }]
426
165
  : [];
@@ -432,12 +171,11 @@ class AnthropicProvider extends BaseLLMProvider {
432
171
  }
433
172
  : undefined;
434
173
 
435
- const statusByStopReason = { refusal: 'refused', max_tokens: 'incomplete' };
436
174
  return {
437
175
  id: response.id,
438
176
  object: 'response',
439
177
  model: response.model,
440
- status: statusByStopReason[response.stop_reason] || 'completed',
178
+ status: response.stop_reason === 'refusal' ? 'refused' : 'completed',
441
179
  stop_reason: response.stop_reason,
442
180
  output,
443
181
  output_text: outputText,