@nexus-cortex/core 4.108.22 → 4.109.0

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 (45) hide show
  1. package/.env.defaults +43 -0
  2. package/dist/config/RuntimeConfigRegistry.d.ts.map +1 -1
  3. package/dist/config/RuntimeConfigRegistry.js +4 -0
  4. package/dist/config/RuntimeConfigRegistry.js.map +1 -1
  5. package/dist/config/SettingsLoader.d.ts.map +1 -1
  6. package/dist/config/SettingsLoader.js +4 -0
  7. package/dist/config/SettingsLoader.js.map +1 -1
  8. package/dist/config/SettingsSchema.d.ts +4 -0
  9. package/dist/config/SettingsSchema.d.ts.map +1 -1
  10. package/dist/config/SettingsSchema.js +36 -0
  11. package/dist/config/SettingsSchema.js.map +1 -1
  12. package/dist/config/effectiveConfig.d.ts.map +1 -1
  13. package/dist/config/effectiveConfig.js +4 -0
  14. package/dist/config/effectiveConfig.js.map +1 -1
  15. package/dist/orchestrator/APIClient.d.ts.map +1 -1
  16. package/dist/orchestrator/APIClient.js +8 -1
  17. package/dist/orchestrator/APIClient.js.map +1 -1
  18. package/dist/orchestrator/CortexOrchestrator.d.ts +10 -0
  19. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  20. package/dist/orchestrator/CortexOrchestrator.js +171 -109
  21. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  22. package/dist/orchestrator/emptyResponseClassifier.d.ts +21 -0
  23. package/dist/orchestrator/emptyResponseClassifier.d.ts.map +1 -1
  24. package/dist/orchestrator/emptyResponseClassifier.js +44 -0
  25. package/dist/orchestrator/emptyResponseClassifier.js.map +1 -1
  26. package/dist/orchestrator/imageRejectionHeal.d.ts +24 -0
  27. package/dist/orchestrator/imageRejectionHeal.d.ts.map +1 -0
  28. package/dist/orchestrator/imageRejectionHeal.js +65 -0
  29. package/dist/orchestrator/imageRejectionHeal.js.map +1 -0
  30. package/dist/orchestrator/timeBudget.d.ts +2 -0
  31. package/dist/orchestrator/timeBudget.d.ts.map +1 -1
  32. package/dist/orchestrator/timeBudget.js +10 -0
  33. package/dist/orchestrator/timeBudget.js.map +1 -1
  34. package/dist/orchestrator/turnEndGuards.d.ts +1 -1
  35. package/dist/orchestrator/turnEndGuards.d.ts.map +1 -1
  36. package/dist/orchestrator/turnEndGuards.js +21 -1
  37. package/dist/orchestrator/turnEndGuards.js.map +1 -1
  38. package/dist/training/DecisionStore.d.ts +1 -1
  39. package/dist/training/DecisionStore.d.ts.map +1 -1
  40. package/dist/training/DecisionStore.js.map +1 -1
  41. package/dist/utils/stateDir.d.ts +11 -0
  42. package/dist/utils/stateDir.d.ts.map +1 -1
  43. package/dist/utils/stateDir.js +53 -1
  44. package/dist/utils/stateDir.js.map +1 -1
  45. package/package.json +3 -3
@@ -48,7 +48,7 @@ import { resolveSubAgentTimeoutMs } from './subAgentTimeout.js';
48
48
  import { resolveHerdrReporting, HerdrReporter, sanitizeHerdrAgentName } from './herdrReporter.js';
49
49
  import { runDelegateInHerdr, readSubAgentRuntimeLever, resolveSubAgentRuntime } from './HerdrSubAgentRunner.js';
50
50
  import { randomBytes } from 'crypto';
51
- import { resolveTurnDeadlineMs, timeBudgetState, timeBudgetWarnNudge, resolveBudgetVisibility, resolveBudgetContinueMinRemaining, budgetBand, budgetVisibilityLine } from './timeBudget.js';
51
+ import { resolveTurnDeadlineMs, timeBudgetState, timeBudgetWarnNudge, resolveBudgetVisibility, resolveBudgetContinueMinRemaining, resolveBudgetContinueMaxNudges, budgetBand, budgetVisibilityLine } from './timeBudget.js';
52
52
  import { readStagedDoctrine, applyCuratedDoctrine, runOrientForStaging, withTimeout } from './doctrineCuration.js';
53
53
  import { ExactRepeatTracker, notePollResults } from '../training/loopLadder.js';
54
54
  import { slashCommandRegistry } from '../commands/SlashCommandRegistry.js';
@@ -80,7 +80,8 @@ import { ListAvailableMcpServers, SearchMcpServers, GetMcpConfig, EnableMcpServe
80
80
  import { InitCortexContext, MemoryWrite, MemoryRecall } from '../tools/context-management/index.js';
81
81
  import { pruneAgedToolResults } from '../conversation/ToolResultPruner.js';
82
82
  import { detectTailRepetition, tailLoopGuardEnabled } from './tailRepetitionDetector.js';
83
- import { classifyEmptyResponse, emptyResponseNudge, nudgeForbidsTools } from './emptyResponseClassifier.js';
83
+ import { classifyEmptyResponse, emptyResponseNudge, nudgeForbidsTools, emptyResponseNudgeFor, isReasoningExhaustion, stepDownEffort, resolveReasoningExhaustBackoff } from './emptyResponseClassifier.js';
84
+ import { isImageRejectionError, stripRejectedImages } from './imageRejectionHeal.js'; // R154
84
85
  // PTC: Progressive tool loading for non-PTC providers
85
86
  import { ClientSideToolFilter } from '../tools/ClientSideToolFilter.js';
86
87
  /** Grace the orchestrator's outer tool-abort waits past the tool's own deadline (ShellTool promote/kill fires first; 4.90.1). */
@@ -1591,6 +1592,9 @@ export class CortexOrchestrator {
1591
1592
  // Keep executing tools until model returns a text response
1592
1593
  let currentAssistantMessage = assistantMessage;
1593
1594
  let currentAssistantCanonicalMessage = assistantCanonicalMessage;
1595
+ // R153: the stop reason of the LATEST response (continuation/retry/gate/synth refresh it) — the empty-response
1596
+ // classifier used to read the turn's FIRST response, so a `length` cutoff on a continuation was never seen.
1597
+ let lastStopReason = convertedResponse.stopReason;
1594
1598
  this.recordDsmlRecovery(currentAssistantCanonicalMessage);
1595
1599
  let toolCallIteration = 0;
1596
1600
  // R137 HB-POLL-REPEAT-BREAKER: the exact-repeat breaker used to force-exit by overwriting
@@ -1651,7 +1655,9 @@ export class CortexOrchestrator {
1651
1655
  this.visionHandoffsThisTurn = 0;
1652
1656
  let endTurnNudges = 0;
1653
1657
  let surrenderNudgeUsed = false; // item 13b: one execute-your-plan nudge per turn
1654
- let budgetContinueNudgeUsed = false; // R151: one continue-with-budget nudge per turn
1658
+ let budgetContinueNudges = 0; // R151/R157: continue-with-budget nudges this turn (bounded by CORTEX_BUDGET_CONTINUE_MAX_NUDGES)
1659
+ const BUDGET_CONTINUE_MAX_NUDGES = resolveBudgetContinueMaxNudges();
1660
+ this.reasoningBackoffRemaining = 0; // R153: backoff is per turn
1655
1661
  const END_TURN_MAX_NUDGES = 2;
1656
1662
  // EndTurn gate / Stages 1-3 are OPT-IN (default OFF). The line-number
1657
1663
  // fabrication they targeted is fully resolved at the root cause:
@@ -1759,7 +1765,7 @@ export class CortexOrchestrator {
1759
1765
  // for observability + a tailored nudge. D-E: stopReason distinguishes a max_tokens truncation
1760
1766
  // ('truncated' → continue) from a demand-final-answer.
1761
1767
  const emptyClass = (!hasVisibleText && !structuredOutputState?.result)
1762
- ? classifyEmptyResponse(currentAssistantCanonicalMessage.content, convertedResponse.stopReason, loopHasBudget)
1768
+ ? classifyEmptyResponse(currentAssistantCanonicalMessage.content, lastStopReason, loopHasBudget)
1763
1769
  : null;
1764
1770
  const isEmptyContinueKind = EMPTY_TURN_CONTINUE && !!emptyClass
1765
1771
  && (emptyClass.kind === 'truncated' || emptyClass.kind === 'reasoning_only_active');
@@ -1775,6 +1781,7 @@ export class CortexOrchestrator {
1775
1781
  }
1776
1782
  console.warn(`[Orchestrator] Empty response detected (${emptyClass.kind}, hadReasoning=${emptyClass.hadReasoning}, iteration=${toolCallIteration}). ` +
1777
1783
  `Retrying once with explicit completion prompt.`);
1784
+ const exhaustionLevel = this.armReasoningBackoffIfExhausted(emptyClass, options.parameters?.reasoningEffort ?? effectiveModel?.reasoning?.effort, toolCallIteration, currentAssistantMessage?.usage?.outputTokens);
1778
1785
  // R26 (2026-05-15, surfaced by A/B benchmark): the empty assistant
1779
1786
  // turn is already in messageHistory. The retry below rebuilds the
1780
1787
  // full history and re-sends it. Anthropic's Messages API tolerates
@@ -1813,7 +1820,7 @@ export class CortexOrchestrator {
1813
1820
  role: 'user',
1814
1821
  content: [{
1815
1822
  type: 'text',
1816
- text: `<system-reminder>${emptyResponseNudge(emptyClass.kind)}${nudgeForbidsTools(emptyClass.kind) ? ' Do not call any more tools.' : ''}</system-reminder>`,
1823
+ text: `<system-reminder>${emptyResponseNudgeFor(emptyClass, exhaustionLevel)}${nudgeForbidsTools(emptyClass.kind) ? ' Do not call any more tools.' : ''}</system-reminder>`,
1817
1824
  }],
1818
1825
  },
1819
1826
  timeline: {
@@ -1833,28 +1840,25 @@ export class CortexOrchestrator {
1833
1840
  // tool-result continuation path (lines ~1395-1466) but without
1834
1841
  // tool_result blocks.
1835
1842
  await this.ensureHistoryFitsModel(effectiveModel);
1836
- const retryCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
1837
- const retryRequest = this.gatewayTranslation.prepareRequest(retryCanonicalHistory, toolsToUse, effectiveModel, {
1838
- temperature: options.parameters?.temperature,
1839
- maxTokens: options.parameters?.maxTokens,
1840
- topP: options.parameters?.topP,
1841
- reasoningEffort: options.parameters?.reasoningEffort,
1842
- stream: options.streaming,
1843
- staticSystemPrompt: this.currentStaticSystemPrompt, // R28
1844
- conversationId: this.currentConversationId, // R28b
1845
- });
1846
- if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
1847
- retryRequest.previousResponseId = this.lastResponseId;
1848
- }
1849
- retryRequest.conversationId = this.currentSessionId;
1850
- let retryApiResponse;
1851
- if (this.retryMiddleware) {
1852
- const retryResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(retryRequest, effectiveModel), 'empty_response_retry');
1853
- retryApiResponse = retryResult.result;
1854
- }
1855
- else {
1856
- retryApiResponse = await this.apiClient.sendRequest(retryRequest, effectiveModel);
1857
- }
1843
+ const retryEffort = this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort; // R153 backoff > request param
1844
+ const buildRetryRequest = () => {
1845
+ const retryCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
1846
+ const req = this.gatewayTranslation.prepareRequest(retryCanonicalHistory, toolsToUse, effectiveModel, {
1847
+ temperature: options.parameters?.temperature,
1848
+ maxTokens: options.parameters?.maxTokens,
1849
+ topP: options.parameters?.topP,
1850
+ reasoningEffort: retryEffort,
1851
+ stream: options.streaming,
1852
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
1853
+ conversationId: this.currentConversationId, // R28b
1854
+ });
1855
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
1856
+ req.previousResponseId = this.lastResponseId;
1857
+ }
1858
+ req.conversationId = this.currentSessionId;
1859
+ return req;
1860
+ };
1861
+ const retryApiResponse = await this.sendWithImageHeal(buildRetryRequest, effectiveModel, 'empty_response_retry');
1858
1862
  const retryConverted = this.gatewayTranslation.convertResponse(retryApiResponse.data, effectiveModel, {
1859
1863
  sessionId: this.currentSessionId,
1860
1864
  conversationId: this.currentConversationId,
@@ -1891,6 +1895,7 @@ export class CortexOrchestrator {
1891
1895
  // Update current pointer so the loop re-evaluates with new content.
1892
1896
  currentAssistantMessage = retryAssistantMessage;
1893
1897
  currentAssistantCanonicalMessage = retryAssistantCanonical;
1898
+ lastStopReason = retryConverted.stopReason; // R153
1894
1899
  continue;
1895
1900
  }
1896
1901
  }
@@ -1962,7 +1967,7 @@ export class CortexOrchestrator {
1962
1967
  temperature: options.parameters?.temperature,
1963
1968
  maxTokens: options.parameters?.maxTokens,
1964
1969
  topP: options.parameters?.topP,
1965
- reasoningEffort: options.parameters?.reasoningEffort,
1970
+ reasoningEffort: this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort, // R153 backoff > request param
1966
1971
  stream: options.streaming,
1967
1972
  staticSystemPrompt: this.currentStaticSystemPrompt, // R28
1968
1973
  conversationId: this.currentConversationId, // R28b
@@ -2014,6 +2019,7 @@ export class CortexOrchestrator {
2014
2019
  await this.historyStore.appendMessage(this.currentSessionId, inactionAssistantMessage);
2015
2020
  currentAssistantMessage = inactionAssistantMessage;
2016
2021
  currentAssistantCanonicalMessage = inactionAssistantCanonical;
2022
+ lastStopReason = inactionConverted.stopReason; // R153
2017
2023
  continue;
2018
2024
  }
2019
2025
  }
@@ -2063,7 +2069,7 @@ export class CortexOrchestrator {
2063
2069
  const budgetContinueMin = resolveBudgetContinueMinRemaining();
2064
2070
  const budgetRemainingFrac = TURN_DEADLINE_MS > 0 ? Math.max(0, 1 - (Date.now() - loopStartMs) / TURN_DEADLINE_MS) : 0;
2065
2071
  const budgetContinueUnsatisfied = !surrenderUnsatisfied && budgetContinueMin > 0 && TURN_DEADLINE_MS > 0 && resolveBudgetVisibility() &&
2066
- ev.usedTools && !budgetContinueNudgeUsed && budgetRemainingFrac >= budgetContinueMin &&
2072
+ ev.usedTools && budgetContinueNudges < BUDGET_CONTINUE_MAX_NUDGES && budgetRemainingFrac >= budgetContinueMin &&
2067
2073
  detectOpenItemsText(surrenderDraftText);
2068
2074
  if (endTurnGateUnsatisfied && endTurnNudges >= END_TURN_MAX_NUDGES) {
2069
2075
  const fallbackReason = !ev.endTurnCalled ? 'missing-EndTurn' : 'coordinate-violation';
@@ -2093,16 +2099,16 @@ export class CortexOrchestrator {
2093
2099
  console.warn('[Orchestrator] Surrender guard: remaining-steps finish detected — execute-your-plan nudge.');
2094
2100
  }
2095
2101
  else if (budgetContinueUnsatisfied) {
2096
- budgetContinueNudgeUsed = true;
2102
+ budgetContinueNudges++;
2097
2103
  const store = this.getDecisionStore();
2098
2104
  if (store) {
2099
2105
  void store.recordEvent({
2100
2106
  sessionId: this.currentSessionId ?? 'unknown',
2101
2107
  kind: 'budget_continue_nudge',
2102
- detail: { iteration: toolCallIteration, remainingFrac: Number(budgetRemainingFrac.toFixed(3)), deadlineMs: TURN_DEADLINE_MS, chars: surrenderDraftText.length },
2108
+ detail: { iteration: toolCallIteration, remainingFrac: Number(budgetRemainingFrac.toFixed(3)), deadlineMs: TURN_DEADLINE_MS, chars: surrenderDraftText.length, nudge: budgetContinueNudges, maxNudges: BUDGET_CONTINUE_MAX_NUDGES },
2103
2109
  }).catch(() => { });
2104
2110
  }
2105
- console.warn(`[Orchestrator] Budget guard (R151): open-items finish with ${Math.round(budgetRemainingFrac * 100)}% of the wall budget left — continue-with-budget nudge.`);
2111
+ console.warn(`[Orchestrator] Budget guard (R151): open-items finish with ${Math.round(budgetRemainingFrac * 100)}% of the wall budget left — continue-with-budget nudge ${budgetContinueNudges}/${BUDGET_CONTINUE_MAX_NUDGES}.`);
2106
2112
  }
2107
2113
  else {
2108
2114
  endTurnNudges++;
@@ -2131,7 +2137,7 @@ export class CortexOrchestrator {
2131
2137
  const endTurnReminderText = surrenderUnsatisfied
2132
2138
  ? SURRENDER_REMINDER
2133
2139
  : budgetContinueUnsatisfied
2134
- ? buildBudgetContinueReminder(TURN_DEADLINE_MS - (Date.now() - loopStartMs), TURN_DEADLINE_MS)
2140
+ ? buildBudgetContinueReminder(TURN_DEADLINE_MS - (Date.now() - loopStartMs), TURN_DEADLINE_MS, budgetContinueNudges)
2135
2141
  : !ev.endTurnCalled
2136
2142
  ? buildMissingEndTurnReminder(ev)
2137
2143
  : ('<system-reminder>EndTurn REJECTED. Your drafted answer asserts line number(s) ' +
@@ -2174,7 +2180,7 @@ export class CortexOrchestrator {
2174
2180
  temperature: options.parameters?.temperature,
2175
2181
  maxTokens: options.parameters?.maxTokens,
2176
2182
  topP: options.parameters?.topP,
2177
- reasoningEffort: options.parameters?.reasoningEffort,
2183
+ reasoningEffort: this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort, // R153 backoff > request param
2178
2184
  stream: options.streaming,
2179
2185
  staticSystemPrompt: this.currentStaticSystemPrompt,
2180
2186
  conversationId: this.currentConversationId,
@@ -2226,6 +2232,7 @@ export class CortexOrchestrator {
2226
2232
  await this.historyStore.appendMessage(this.currentSessionId, gateAssistantMessage);
2227
2233
  currentAssistantMessage = gateAssistantMessage;
2228
2234
  currentAssistantCanonicalMessage = gateAssistantCanonical;
2235
+ lastStopReason = gateConverted.stopReason; // R153
2229
2236
  continue;
2230
2237
  }
2231
2238
  }
@@ -2826,53 +2833,47 @@ export class CortexOrchestrator {
2826
2833
  const continuationHistorySource = canSliceInput
2827
2834
  ? this.messageHistory.slice(this.messageCountAtLastResponse)
2828
2835
  : this.messageHistory;
2829
- const continuationCanonicalHistory = this.convertToCanonicalMessages([...continuationHistorySource]);
2830
2836
  // §13-B2: re-evaluate the forced mentor choice on THIS continuation — thrash develops HERE
2831
2837
  // (turnNumber ≥ 1 with accumulating failing outcomes), NOT on the initial request (turnNumber 0
2832
2838
  // where the window is empty). Attaching it only to the initial assembly made the force unreachable.
2833
2839
  const continuationForcedChoice = await this.resolveForcedMentorChoice(toolsToUse, toolCallIteration);
2834
- const continuationRequest = this.gatewayTranslation.prepareRequest(continuationCanonicalHistory, toolsToUse, effectiveModel, {
2835
- temperature: options.parameters?.temperature,
2836
- maxTokens: options.parameters?.maxTokens,
2837
- topP: options.parameters?.topP,
2838
- reasoningEffort: this.consumeEffortPulse() ?? options.parameters?.reasoningEffort, // effort pulse > request param > card
2839
- stream: options.streaming,
2840
- staticSystemPrompt: this.currentStaticSystemPrompt, // R28
2841
- conversationId: this.currentConversationId, // R28b
2842
- toolChoice: continuationForcedChoice // §13-B2 forced mentor tool_choice (continuation)
2843
- });
2844
- // Stateful Responses API: chain with previous_response_id for server-side reasoning preservation
2845
- if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
2846
- continuationRequest.previousResponseId = this.lastResponseId;
2847
- }
2840
+ // R153: resolve the effort ONCE (the backoff/pulse counters are consumed per call, not per build).
2841
+ const continuationEffort = this.consumeReasoningBackoff() ?? this.consumeEffortPulse() ?? options.parameters?.reasoningEffort; // R153 backoff > effort pulse > request param > card
2842
+ // R154: the request is built by a closure so the image-rejection heal can rebuild it from the
2843
+ // (stubbed) history and retry once. Byte-identical request when no heal is needed.
2844
+ const buildContinuationRequest = () => {
2845
+ const continuationCanonicalHistory = this.convertToCanonicalMessages([...continuationHistorySource]);
2846
+ const req = this.gatewayTranslation.prepareRequest(continuationCanonicalHistory, toolsToUse, effectiveModel, {
2847
+ temperature: options.parameters?.temperature,
2848
+ maxTokens: options.parameters?.maxTokens,
2849
+ topP: options.parameters?.topP,
2850
+ reasoningEffort: continuationEffort,
2851
+ stream: options.streaming,
2852
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
2853
+ conversationId: this.currentConversationId, // R28b
2854
+ toolChoice: continuationForcedChoice // §13-B2 forced mentor tool_choice (continuation)
2855
+ });
2856
+ // Stateful Responses API: chain with previous_response_id for server-side reasoning preservation
2857
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
2858
+ req.previousResponseId = this.lastResponseId;
2859
+ }
2860
+ // Cache-routing conversation id
2861
+ req.conversationId = this.currentSessionId;
2862
+ // PTC: Override tools with defer_loading + PTC system tools on continuation
2863
+ if (isPTCEnabled && toolsToUse && toolsToUse.length > 0) {
2864
+ req.tools = this.gatewayTranslation.prepareToolsWithPTC(toolsToUse, effectiveModel);
2865
+ req.parameters.enablePTC = true;
2866
+ }
2867
+ return req;
2868
+ };
2848
2869
  if (canSliceInput && this.config.debug) {
2849
2870
  console.log(`[Orchestrator Phase 2.5] Input-sliced for previous_response_id: sent ${continuationHistorySource.length}/${this.messageHistory.length} messages`);
2850
2871
  }
2851
- // Cache-routing conversation id
2852
- continuationRequest.conversationId = this.currentSessionId;
2853
- // PTC: Override tools with defer_loading + PTC system tools on continuation
2854
- if (isPTCEnabled && toolsToUse && toolsToUse.length > 0) {
2855
- continuationRequest.tools = this.gatewayTranslation.prepareToolsWithPTC(toolsToUse, effectiveModel);
2856
- continuationRequest.parameters.enablePTC = true;
2857
- }
2858
2872
  if (this.config.debug) {
2859
2873
  console.log(`[Orchestrator Phase 2.5] Sending tool results back to model for continuation...`);
2860
2874
  }
2861
- // Send continuation request with retry logic
2862
- let continuationApiResponse;
2863
- if (this.retryMiddleware) {
2864
- const retryResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(continuationRequest, effectiveModel), 'continuation_api_call');
2865
- continuationApiResponse = retryResult.result;
2866
- // Log retry information if retries occurred
2867
- if (this.config.debug && retryResult.attemptCount > 1) {
2868
- console.log(`[Orchestrator] Continuation API call succeeded after ${retryResult.attemptCount} attempts`);
2869
- console.log(`[Orchestrator] Total retry time: ${retryResult.totalDelayMs}ms`);
2870
- }
2871
- }
2872
- else {
2873
- // Fallback: direct call if middleware not available
2874
- continuationApiResponse = await this.apiClient.sendRequest(continuationRequest, effectiveModel);
2875
- }
2875
+ // Send continuation request with retry logic (network ladder inside; image-rejection heal around it — R154)
2876
+ const continuationApiResponse = await this.sendWithImageHeal(buildContinuationRequest, effectiveModel, 'continuation_api_call');
2876
2877
  // Convert continuation response
2877
2878
  const continuationConvertedResponse = this.gatewayTranslation.convertResponse(continuationApiResponse.data, effectiveModel, {
2878
2879
  sessionId: this.currentSessionId,
@@ -2934,6 +2935,7 @@ export class CortexOrchestrator {
2934
2935
  // Update current message for next iteration
2935
2936
  currentAssistantMessage = continuationAssistantMessage;
2936
2937
  currentAssistantCanonicalMessage = continuationAssistantCanonicalMessage;
2938
+ lastStopReason = continuationConvertedResponse.stopReason; // R153
2937
2939
  // Phase 2.5 Day 3: Stop if too many errors accumulated
2938
2940
  if (totalToolErrors >= MAX_CONSECUTIVE_ERRORS) {
2939
2941
  console.warn(`[Orchestrator Phase 2.5] Stopping loop due to accumulated errors`);
@@ -3112,36 +3114,33 @@ export class CortexOrchestrator {
3112
3114
  this.messageHistory.push(synthUserMessage);
3113
3115
  await this.historyStore.appendMessage(this.currentSessionId, synthUserMessage);
3114
3116
  await this.ensureHistoryFitsModel(effectiveModel);
3115
- const synthCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
3116
- const synthRequest = this.gatewayTranslation.prepareRequest(synthCanonicalHistory,
3117
- // Tools suppressed — the model MUST produce text, not call more tools.
3118
- // NOTE (structured-output constraint, 2026-08-02): this is R29a's
3119
- // plain-text escape hatch for tool-loop EXHAUSTION (30+ calls). A
3120
- // jsonSchema turn that gets here degrades to text; StructuredOutput
3121
- // is deliberately NOT re-forced (this terminal synth turn has no
3122
- // tool-interception, so a StructuredOutput call would go uncaptured —
3123
- // worse than the graceful text fallback + structuredOutput.valid=false).
3124
- [], effectiveModel, {
3125
- temperature: options.parameters?.temperature,
3126
- maxTokens: options.parameters?.maxTokens,
3127
- topP: options.parameters?.topP,
3128
- reasoningEffort: options.parameters?.reasoningEffort,
3129
- stream: options.streaming,
3130
- staticSystemPrompt: this.currentStaticSystemPrompt, // R28
3131
- conversationId: this.currentConversationId, // R28b
3132
- });
3133
- if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
3134
- synthRequest.previousResponseId = this.lastResponseId;
3135
- }
3136
- synthRequest.conversationId = this.currentSessionId;
3137
- let synthApiResponse;
3138
- if (this.retryMiddleware) {
3139
- const synthResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(synthRequest, effectiveModel), 'r29a_synthesis_retry');
3140
- synthApiResponse = synthResult.result;
3141
- }
3142
- else {
3143
- synthApiResponse = await this.apiClient.sendRequest(synthRequest, effectiveModel);
3144
- }
3117
+ const synthEffort = this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort; // R153 backoff > request param
3118
+ const buildSynthRequest = () => {
3119
+ const synthCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
3120
+ const req = this.gatewayTranslation.prepareRequest(synthCanonicalHistory,
3121
+ // Tools suppressed — the model MUST produce text, not call more tools.
3122
+ // NOTE (structured-output constraint, 2026-08-02): this is R29a's
3123
+ // plain-text escape hatch for tool-loop EXHAUSTION (30+ calls). A
3124
+ // jsonSchema turn that gets here degrades to text; StructuredOutput
3125
+ // is deliberately NOT re-forced (this terminal synth turn has no
3126
+ // tool-interception, so a StructuredOutput call would go uncaptured —
3127
+ // worse than the graceful text fallback + structuredOutput.valid=false).
3128
+ [], effectiveModel, {
3129
+ temperature: options.parameters?.temperature,
3130
+ maxTokens: options.parameters?.maxTokens,
3131
+ topP: options.parameters?.topP,
3132
+ reasoningEffort: synthEffort,
3133
+ stream: options.streaming,
3134
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
3135
+ conversationId: this.currentConversationId, // R28b
3136
+ });
3137
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
3138
+ req.previousResponseId = this.lastResponseId;
3139
+ }
3140
+ req.conversationId = this.currentSessionId;
3141
+ return req;
3142
+ };
3143
+ const synthApiResponse = await this.sendWithImageHeal(buildSynthRequest, effectiveModel, 'r29a_synthesis_retry');
3145
3144
  const synthConverted = this.gatewayTranslation.convertResponse(synthApiResponse.data, effectiveModel, {
3146
3145
  sessionId: this.currentSessionId,
3147
3146
  conversationId: this.currentConversationId,
@@ -3182,6 +3181,7 @@ export class CortexOrchestrator {
3182
3181
  }
3183
3182
  currentAssistantMessage = synthAssistantMessage;
3184
3183
  currentAssistantCanonicalMessage = synthAssistantCanonical;
3184
+ lastStopReason = synthConverted.stopReason; // R153
3185
3185
  }
3186
3186
  }
3187
3187
  catch (synthErr) {
@@ -3777,6 +3777,7 @@ export class CortexOrchestrator {
3777
3777
  throw new Error('No response message returned from provider');
3778
3778
  }
3779
3779
  let currentAssistantCanonicalMessage = convertedResponse.messages[0];
3780
+ let lastStopReason = convertedResponse.stopReason; // R153 (see non-streaming loop)
3780
3781
  this.recordDsmlRecovery(currentAssistantCanonicalMessage);
3781
3782
  const assistantMessageId = currentAssistantCanonicalMessage.uuid;
3782
3783
  const assistantMessage = {
@@ -3926,7 +3927,7 @@ export class CortexOrchestrator {
3926
3927
  && (TURN_DEADLINE_MS <= 0 || (Date.now() - loopStartMs) < 0.6 * TURN_DEADLINE_MS)
3927
3928
  && toolCallIteration < 0.6 * MAX_TOOL_ITERATIONS;
3928
3929
  const emptyClass = !hasVisibleText
3929
- ? classifyEmptyResponse(currentAssistantCanonicalMessage.content, undefined, loopHasBudget)
3930
+ ? classifyEmptyResponse(currentAssistantCanonicalMessage.content, lastStopReason, loopHasBudget)
3930
3931
  : null;
3931
3932
  const isEmptyContinueKind = EMPTY_TURN_CONTINUE && !!emptyClass
3932
3933
  && (emptyClass.kind === 'truncated' || emptyClass.kind === 'reasoning_only_active');
@@ -3942,6 +3943,7 @@ export class CortexOrchestrator {
3942
3943
  }
3943
3944
  console.warn(`[Orchestrator Streaming] R32/R18b: Empty response (${emptyClass.kind}, iteration=${toolCallIteration}). ` +
3944
3945
  `Retrying with tools preserved.`);
3946
+ const exhaustionLevel = this.armReasoningBackoffIfExhausted(emptyClass, options.parameters?.reasoningEffort ?? effectiveModel?.reasoning?.effort, toolCallIteration, this.messageHistory[this.messageHistory.length - 1]?.usage?.outputTokens);
3945
3947
  // R26 repair: ensure the empty assistant turn has content (xAI hard-400s on empty)
3946
3948
  for (let i = this.messageHistory.length - 1; i >= 0; i--) {
3947
3949
  const m = this.messageHistory[i];
@@ -3966,7 +3968,7 @@ export class CortexOrchestrator {
3966
3968
  role: 'user',
3967
3969
  content: [{
3968
3970
  type: 'text',
3969
- text: `<system-reminder>${emptyResponseNudge(emptyClass.kind)}${nudgeForbidsTools(emptyClass.kind) ? ' Do not call any more tools.' : ''}</system-reminder>`,
3971
+ text: `<system-reminder>${emptyResponseNudgeFor(emptyClass, exhaustionLevel)}${nudgeForbidsTools(emptyClass.kind) ? ' Do not call any more tools.' : ''}</system-reminder>`,
3970
3972
  }],
3971
3973
  },
3972
3974
  timeline: {
@@ -3989,7 +3991,7 @@ export class CortexOrchestrator {
3989
3991
  temperature: options.parameters?.temperature,
3990
3992
  maxTokens: options.parameters?.maxTokens,
3991
3993
  topP: options.parameters?.topP,
3992
- reasoningEffort: options.parameters?.reasoningEffort,
3994
+ reasoningEffort: this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort, // R153 backoff > request param
3993
3995
  stream: false,
3994
3996
  staticSystemPrompt: this.currentStaticSystemPrompt,
3995
3997
  conversationId: this.currentConversationId,
@@ -4040,6 +4042,7 @@ export class CortexOrchestrator {
4040
4042
  this.messageHistory.push(retryAssistantMessage);
4041
4043
  await this.historyStore.appendMessage(this.currentSessionId, retryAssistantMessage);
4042
4044
  currentAssistantCanonicalMessage = retryAssistantCanonical;
4045
+ lastStopReason = retryConverted.stopReason; // R153
4043
4046
  // Yield any text from the retry as streaming chunks
4044
4047
  const retryText = retryAssistantCanonical.content
4045
4048
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
@@ -4119,7 +4122,7 @@ export class CortexOrchestrator {
4119
4122
  temperature: options.parameters?.temperature,
4120
4123
  maxTokens: options.parameters?.maxTokens,
4121
4124
  topP: options.parameters?.topP,
4122
- reasoningEffort: options.parameters?.reasoningEffort,
4125
+ reasoningEffort: this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort, // R153 backoff > request param
4123
4126
  stream: false,
4124
4127
  staticSystemPrompt: this.currentStaticSystemPrompt,
4125
4128
  conversationId: this.currentConversationId,
@@ -4170,6 +4173,7 @@ export class CortexOrchestrator {
4170
4173
  this.messageHistory.push(retryAssistantMessage);
4171
4174
  await this.historyStore.appendMessage(this.currentSessionId, retryAssistantMessage);
4172
4175
  currentAssistantCanonicalMessage = retryAssistantCanonical;
4176
+ lastStopReason = retryConverted.stopReason; // R153
4173
4177
  const retryText = retryAssistantCanonical.content
4174
4178
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4175
4179
  .map((b) => b.text)
@@ -4603,7 +4607,7 @@ export class CortexOrchestrator {
4603
4607
  temperature: options.parameters?.temperature,
4604
4608
  maxTokens: options.parameters?.maxTokens,
4605
4609
  topP: options.parameters?.topP,
4606
- reasoningEffort: this.consumeEffortPulse() ?? options.parameters?.reasoningEffort, // effort pulse > request param > card
4610
+ reasoningEffort: this.consumeReasoningBackoff() ?? this.consumeEffortPulse() ?? options.parameters?.reasoningEffort, // R153 backoff > effort pulse > request param > card
4607
4611
  stream: true, // STREAMING continuation
4608
4612
  staticSystemPrompt: this.currentStaticSystemPrompt, // R28
4609
4613
  conversationId: this.currentConversationId, // R28b
@@ -4683,8 +4687,9 @@ export class CortexOrchestrator {
4683
4687
  }
4684
4688
  const finishReason = continuationProviderMessage.choices[0]?.finish_reason;
4685
4689
  if (finishReason === 'length') {
4686
- console.warn(`[Orchestrator Streaming] Chat Completions API hit max tokens. Stopping tool loop.`);
4687
- break;
4690
+ // R153: do NOT leave the loop — the empty-response classifier below sees `truncated` (via lastStopReason)
4691
+ // and continues with the effort backoff; a partial text answer simply ends the turn as before.
4692
+ console.warn(`[Orchestrator Streaming] Chat Completions API hit max tokens (finish_reason=length) — handing to the truncation path.`);
4688
4693
  }
4689
4694
  }
4690
4695
  // Convert continuation response
@@ -4740,6 +4745,7 @@ export class CortexOrchestrator {
4740
4745
  }
4741
4746
  // Update for next iteration
4742
4747
  currentAssistantCanonicalMessage = continuationAssistantCanonicalMessage;
4748
+ lastStopReason = continuationConvertedResponse.stopReason; // R153
4743
4749
  // Check if continuation has more tool uses
4744
4750
  const continuationToolUses = continuationAssistantCanonicalMessage.content
4745
4751
  .filter((block) => block.type === 'tool_use')
@@ -4942,7 +4948,7 @@ export class CortexOrchestrator {
4942
4948
  temperature: options.parameters?.temperature,
4943
4949
  maxTokens: options.parameters?.maxTokens,
4944
4950
  topP: options.parameters?.topP,
4945
- reasoningEffort: options.parameters?.reasoningEffort,
4951
+ reasoningEffort: this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort, // R153 backoff > request param
4946
4952
  stream: false,
4947
4953
  staticSystemPrompt: this.currentStaticSystemPrompt, // R28
4948
4954
  conversationId: this.currentConversationId, // R28b
@@ -4998,6 +5004,7 @@ export class CortexOrchestrator {
4998
5004
  await this.historyStore.appendMessage(this.currentSessionId, synthAssistantMessage);
4999
5005
  this.sessionTimeline.recordMessage(synthAssistantMessage.uuid, 'assistant');
5000
5006
  currentAssistantCanonicalMessage = synthCanonical;
5007
+ lastStopReason = synthConverted.stopReason; // R153
5001
5008
  if (synthConverted.usage) {
5002
5009
  this.cacheMetricsAccumulator.addUsage(synthConverted.usage, effectiveModel.provider);
5003
5010
  this.noteRequestUsage(synthConverted.usage); // R132
@@ -8166,6 +8173,61 @@ export class CortexOrchestrator {
8166
8173
  this.effortPulseRemaining -= 1;
8167
8174
  return (process.env.CORTEX_EFFORT_PULSE_LEVEL || 'high');
8168
8175
  }
8176
+ /** R153 HB-REASONING-EXHAUSTION: continuations left at a LOWERED reasoning effort after a truncated
8177
+ * reasoning-only turn (output cap hit, nothing delivered). Takes precedence over the effort pulse. */
8178
+ reasoningBackoffRemaining = 0;
8179
+ reasoningBackoffLevel;
8180
+ consumeReasoningBackoff() {
8181
+ if (this.reasoningBackoffRemaining <= 0)
8182
+ return undefined;
8183
+ this.reasoningBackoffRemaining -= 1;
8184
+ return this.reasoningBackoffLevel;
8185
+ }
8186
+ /** Arms the backoff when the empty turn is a reasoning exhaustion; returns the level the next calls will use. */
8187
+ armReasoningBackoffIfExhausted(cls, effectiveEffort, iteration, outputTokens) {
8188
+ if (!isReasoningExhaustion(cls))
8189
+ return undefined;
8190
+ const cfg = resolveReasoningExhaustBackoff();
8191
+ const store = this.getDecisionStore();
8192
+ if (!cfg.enabled) {
8193
+ console.warn(`[Orchestrator] R153: reasoning exhaustion at iteration ${iteration} (output cap hit, nothing delivered) — backoff disabled (CORTEX_REASONING_EXHAUST_BACKOFF=false).`);
8194
+ if (store)
8195
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'reasoning_exhaustion', detail: { iteration, outputTokens, backoff: false } }).catch(() => { });
8196
+ return undefined;
8197
+ }
8198
+ // A repeat exhaustion while a backoff is already armed steps down AGAIN (high → medium → low).
8199
+ const base = this.reasoningBackoffRemaining > 0 && this.reasoningBackoffLevel ? this.reasoningBackoffLevel : effectiveEffort;
8200
+ const level = stepDownEffort(base);
8201
+ this.reasoningBackoffLevel = level;
8202
+ this.reasoningBackoffRemaining = cfg.turns;
8203
+ console.warn(`[Orchestrator] R153: reasoning exhaustion at iteration ${iteration} (${outputTokens ?? '?'} output tokens, no text/tool) — next ${cfg.turns} continuation(s) at effort '${level}' (was '${base ?? 'card default'}').`);
8204
+ if (store)
8205
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'reasoning_exhaustion', detail: { iteration, outputTokens, from: base ?? null, level, turns: cfg.turns } }).catch(() => { });
8206
+ return level;
8207
+ }
8208
+ /** R154 HB-IMAGE-REJECTION-HEAL: send a request built from messageHistory; if the provider rejects an image
8209
+ * block (4xx "unsupported image"), stub every user-side image block and retry the SAME build once. */
8210
+ async sendWithImageHeal(build, model, label) {
8211
+ const send = async (req) => this.retryMiddleware
8212
+ ? (await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(req, model), label)).result
8213
+ : this.apiClient.sendRequest(req, model);
8214
+ try {
8215
+ return await send(build());
8216
+ }
8217
+ catch (err) {
8218
+ if (!isImageRejectionError(err))
8219
+ throw err;
8220
+ const reason = String(err?.message ?? err).slice(0, 300);
8221
+ const stripped = stripRejectedImages(this.messageHistory, reason);
8222
+ if (stripped === 0)
8223
+ throw err;
8224
+ console.warn(`[Orchestrator] R154: provider rejected an image block on ${label} — stubbed ${stripped} image block(s) in history and retrying once.`);
8225
+ const store = this.getDecisionStore();
8226
+ if (store)
8227
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'image_rejection_heal', detail: { stripped, label, reason: reason.slice(0, 160) } }).catch(() => { });
8228
+ return await send(build());
8229
+ }
8230
+ }
8169
8231
  /** Honored AskForAdvice consults per session (rate-limit + rung state; persists across turns). */
8170
8232
  mentorConsultCounts = new Map();
8171
8233
  /**