@nexus-cortex/core 4.108.22 → 4.110.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 (55) hide show
  1. package/.env.defaults +56 -0
  2. package/dist/config/RuntimeConfigRegistry.d.ts.map +1 -1
  3. package/dist/config/RuntimeConfigRegistry.js +5 -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 +5 -0
  7. package/dist/config/SettingsLoader.js.map +1 -1
  8. package/dist/config/SettingsSchema.d.ts +5 -0
  9. package/dist/config/SettingsSchema.d.ts.map +1 -1
  10. package/dist/config/SettingsSchema.js +45 -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 +5 -0
  14. package/dist/config/effectiveConfig.js.map +1 -1
  15. package/dist/middleware/helpers/adapters/ChatCompletionsAPIHelperAdapter.d.ts.map +1 -1
  16. package/dist/middleware/helpers/adapters/ChatCompletionsAPIHelperAdapter.js +3 -1
  17. package/dist/middleware/helpers/adapters/ChatCompletionsAPIHelperAdapter.js.map +1 -1
  18. package/dist/orchestrator/APIClient.d.ts.map +1 -1
  19. package/dist/orchestrator/APIClient.js +8 -1
  20. package/dist/orchestrator/APIClient.js.map +1 -1
  21. package/dist/orchestrator/CortexOrchestrator.d.ts +10 -0
  22. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  23. package/dist/orchestrator/CortexOrchestrator.js +270 -175
  24. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  25. package/dist/orchestrator/emptyResponseClassifier.d.ts +21 -0
  26. package/dist/orchestrator/emptyResponseClassifier.d.ts.map +1 -1
  27. package/dist/orchestrator/emptyResponseClassifier.js +44 -0
  28. package/dist/orchestrator/emptyResponseClassifier.js.map +1 -1
  29. package/dist/orchestrator/imageRejectionHeal.d.ts +24 -0
  30. package/dist/orchestrator/imageRejectionHeal.d.ts.map +1 -0
  31. package/dist/orchestrator/imageRejectionHeal.js +65 -0
  32. package/dist/orchestrator/imageRejectionHeal.js.map +1 -0
  33. package/dist/orchestrator/timeBudget.d.ts +2 -0
  34. package/dist/orchestrator/timeBudget.d.ts.map +1 -1
  35. package/dist/orchestrator/timeBudget.js +10 -0
  36. package/dist/orchestrator/timeBudget.js.map +1 -1
  37. package/dist/orchestrator/turnEndGuards.d.ts +1 -1
  38. package/dist/orchestrator/turnEndGuards.d.ts.map +1 -1
  39. package/dist/orchestrator/turnEndGuards.js +21 -1
  40. package/dist/orchestrator/turnEndGuards.js.map +1 -1
  41. package/dist/tools/registries/BaseToolRegistry.d.ts.map +1 -1
  42. package/dist/tools/registries/BaseToolRegistry.js +1 -0
  43. package/dist/tools/registries/BaseToolRegistry.js.map +1 -1
  44. package/dist/training/DecisionStore.d.ts +1 -1
  45. package/dist/training/DecisionStore.d.ts.map +1 -1
  46. package/dist/training/DecisionStore.js.map +1 -1
  47. package/dist/training/mentorRole.d.ts +2 -0
  48. package/dist/training/mentorRole.d.ts.map +1 -1
  49. package/dist/training/mentorRole.js +7 -2
  50. package/dist/training/mentorRole.js.map +1 -1
  51. package/dist/utils/stateDir.d.ts +11 -0
  52. package/dist/utils/stateDir.d.ts.map +1 -1
  53. package/dist/utils/stateDir.js +53 -1
  54. package/dist/utils/stateDir.js.map +1 -1
  55. 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: {
@@ -3984,28 +3986,25 @@ export class CortexOrchestrator {
3984
3986
  await this.historyStore.appendMessage(this.currentSessionId, followupUserMessage);
3985
3987
  try {
3986
3988
  await this.ensureHistoryFitsModel(effectiveModel);
3987
- const retryCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
3988
- const retryRequest = this.gatewayTranslation.prepareRequest(retryCanonicalHistory, toolsToUse, effectiveModel, {
3989
- temperature: options.parameters?.temperature,
3990
- maxTokens: options.parameters?.maxTokens,
3991
- topP: options.parameters?.topP,
3992
- reasoningEffort: options.parameters?.reasoningEffort,
3993
- stream: false,
3994
- staticSystemPrompt: this.currentStaticSystemPrompt,
3995
- conversationId: this.currentConversationId,
3996
- });
3997
- if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
3998
- retryRequest.previousResponseId = this.lastResponseId;
3999
- }
4000
- retryRequest.conversationId = this.currentSessionId;
4001
- let retryApiResponse;
4002
- if (this.retryMiddleware) {
4003
- const retryResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(retryRequest, effectiveModel), 'r32_stream_empty_retry');
4004
- retryApiResponse = retryResult.result;
4005
- }
4006
- else {
4007
- retryApiResponse = await this.apiClient.sendRequest(retryRequest, effectiveModel);
4008
- }
3989
+ const retryEffort = this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort; // R153 backoff > request param
3990
+ const buildRetryRequest = () => {
3991
+ const retryCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
3992
+ const req = this.gatewayTranslation.prepareRequest(retryCanonicalHistory, toolsToUse, effectiveModel, {
3993
+ temperature: options.parameters?.temperature,
3994
+ maxTokens: options.parameters?.maxTokens,
3995
+ topP: options.parameters?.topP,
3996
+ reasoningEffort: retryEffort,
3997
+ stream: false,
3998
+ staticSystemPrompt: this.currentStaticSystemPrompt,
3999
+ conversationId: this.currentConversationId,
4000
+ });
4001
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
4002
+ req.previousResponseId = this.lastResponseId;
4003
+ }
4004
+ req.conversationId = this.currentSessionId;
4005
+ return req;
4006
+ };
4007
+ const retryApiResponse = await this.sendWithImageHeal(buildRetryRequest, effectiveModel, 'r32_stream_empty_retry');
4009
4008
  const retryConverted = this.gatewayTranslation.convertResponse(retryApiResponse.data, effectiveModel, {
4010
4009
  sessionId: this.currentSessionId,
4011
4010
  conversationId: this.currentConversationId,
@@ -4040,6 +4039,7 @@ export class CortexOrchestrator {
4040
4039
  this.messageHistory.push(retryAssistantMessage);
4041
4040
  await this.historyStore.appendMessage(this.currentSessionId, retryAssistantMessage);
4042
4041
  currentAssistantCanonicalMessage = retryAssistantCanonical;
4042
+ lastStopReason = retryConverted.stopReason; // R153
4043
4043
  // Yield any text from the retry as streaming chunks
4044
4044
  const retryText = retryAssistantCanonical.content
4045
4045
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
@@ -4119,7 +4119,7 @@ export class CortexOrchestrator {
4119
4119
  temperature: options.parameters?.temperature,
4120
4120
  maxTokens: options.parameters?.maxTokens,
4121
4121
  topP: options.parameters?.topP,
4122
- reasoningEffort: options.parameters?.reasoningEffort,
4122
+ reasoningEffort: this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort, // R153 backoff > request param
4123
4123
  stream: false,
4124
4124
  staticSystemPrompt: this.currentStaticSystemPrompt,
4125
4125
  conversationId: this.currentConversationId,
@@ -4170,6 +4170,7 @@ export class CortexOrchestrator {
4170
4170
  this.messageHistory.push(retryAssistantMessage);
4171
4171
  await this.historyStore.appendMessage(this.currentSessionId, retryAssistantMessage);
4172
4172
  currentAssistantCanonicalMessage = retryAssistantCanonical;
4173
+ lastStopReason = retryConverted.stopReason; // R153
4173
4174
  const retryText = retryAssistantCanonical.content
4174
4175
  .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
4175
4176
  .map((b) => b.text)
@@ -4592,25 +4593,30 @@ export class CortexOrchestrator {
4592
4593
  const streamContinuationHistorySource = streamCanSliceInput
4593
4594
  ? this.messageHistory.slice(this.messageCountAtLastResponse)
4594
4595
  : this.messageHistory;
4595
- const continuationCanonicalHistory = this.convertToCanonicalMessages([...streamContinuationHistorySource]);
4596
4596
  // §13-B2: re-evaluate the forced mentor choice on THIS streaming continuation — thrash
4597
4597
  // develops here, not on the initial request (mirror of the non-stream continuation fix).
4598
4598
  const continuationForcedChoice = await this.resolveForcedMentorChoice(toolsToUse, toolCallIteration);
4599
- // Phase 2.8: Prepare continuation request
4599
+ const streamContinuationEffort = this.consumeReasoningBackoff() ?? this.consumeEffortPulse() ?? options.parameters?.reasoningEffort; // R153 backoff > effort pulse > request param > card
4600
+ // Phase 2.8: Prepare continuation request (R154: built by a closure so the image-rejection heal can
4601
+ // rebuild it from the stubbed history and re-stream once; byte-identical request otherwise).
4600
4602
  // All providers maintain thinking throughout multi-turn execution
4601
- const continuationRequest = this.gatewayTranslation.prepareRequest(continuationCanonicalHistory, toolsToUse, // Updated tools including SearchTools discoveries
4602
- effectiveModel, {
4603
- temperature: options.parameters?.temperature,
4604
- maxTokens: options.parameters?.maxTokens,
4605
- topP: options.parameters?.topP,
4606
- reasoningEffort: this.consumeEffortPulse() ?? options.parameters?.reasoningEffort, // effort pulse > request param > card
4607
- stream: true, // STREAMING continuation
4608
- staticSystemPrompt: this.currentStaticSystemPrompt, // R28
4609
- conversationId: this.currentConversationId, // R28b
4610
- toolChoice: continuationForcedChoice // §13-B2 forced mentor tool_choice (continuation)
4611
- // Thinking enabled for all providers in continuations
4612
- // This allows interleaved thinking during tool execution
4613
- });
4603
+ const buildStreamContinuationRequest = () => {
4604
+ const continuationCanonicalHistory = this.convertToCanonicalMessages([...streamContinuationHistorySource]);
4605
+ return this.gatewayTranslation.prepareRequest(continuationCanonicalHistory, toolsToUse, // Updated tools including SearchTools discoveries
4606
+ effectiveModel, {
4607
+ temperature: options.parameters?.temperature,
4608
+ maxTokens: options.parameters?.maxTokens,
4609
+ topP: options.parameters?.topP,
4610
+ reasoningEffort: streamContinuationEffort,
4611
+ stream: true, // STREAMING continuation
4612
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
4613
+ conversationId: this.currentConversationId, // R28b
4614
+ toolChoice: continuationForcedChoice // §13-B2 forced mentor tool_choice (continuation)
4615
+ // Thinking enabled for all providers in continuations
4616
+ // This allows interleaved thinking during tool execution
4617
+ });
4618
+ };
4619
+ const continuationRequest = buildStreamContinuationRequest();
4614
4620
  // Stateful Responses API: chain with previous_response_id for server-side reasoning preservation
4615
4621
  if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
4616
4622
  continuationRequest.previousResponseId = this.lastResponseId;
@@ -4628,18 +4634,41 @@ export class CortexOrchestrator {
4628
4634
  if (this.config.debug) {
4629
4635
  console.log(`[Orchestrator Streaming] Sending tool results back to model for continuation (iteration ${toolCallIteration})...`);
4630
4636
  }
4631
- // NEW: Stream continuation response
4632
- const continuationStreamingResponse = this.apiClient.streamRequest(continuationRequest, effectiveModel);
4633
4637
  // Yield a marker so CLI knows this is a continuation
4634
4638
  if (process.env.DEBUG_THINKING === 'true') {
4635
4639
  console.log(`\n[DEBUG Orchestrator] === CONTINUATION ${toolCallIteration} STARTING ===`);
4636
4640
  }
4637
- // Yield continuation chunks in real-time
4638
- for await (const chunk of continuationStreamingResponse.chunks) {
4639
- yield chunk;
4641
+ // NEW: Stream continuation response. R154: one heal attempt — if the provider rejects an image block
4642
+ // (a request-shape 4xx raised before any chunk arrives), stub the image blocks and re-stream the
4643
+ // rebuilt request once; every other error propagates exactly as before.
4644
+ let continuationProviderMessage;
4645
+ for (let healAttempt = 0;; healAttempt++) {
4646
+ const streamReq = healAttempt === 0 ? continuationRequest : buildStreamContinuationRequest();
4647
+ const continuationStreamingResponse = this.apiClient.streamRequest(streamReq, effectiveModel);
4648
+ try {
4649
+ // Yield continuation chunks in real-time
4650
+ for await (const chunk of continuationStreamingResponse.chunks) {
4651
+ yield chunk;
4652
+ }
4653
+ // Get final continuation message
4654
+ continuationProviderMessage = await continuationStreamingResponse.finalMessage;
4655
+ break;
4656
+ }
4657
+ catch (streamErr) {
4658
+ if (healAttempt === 0 && isImageRejectionError(streamErr)) {
4659
+ const reason = String(streamErr?.message ?? streamErr).slice(0, 300);
4660
+ const stripped = stripRejectedImages(this.messageHistory, reason);
4661
+ if (stripped > 0) {
4662
+ console.warn(`[Orchestrator Streaming] R154: provider rejected an image block on the continuation — stubbed ${stripped} image block(s) in history and re-streaming once.`);
4663
+ const healStore = this.getDecisionStore();
4664
+ if (healStore)
4665
+ void healStore.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'image_rejection_heal', detail: { stripped, label: 'stream_continuation', reason: reason.slice(0, 160) } }).catch(() => { });
4666
+ continue;
4667
+ }
4668
+ }
4669
+ throw streamErr;
4670
+ }
4640
4671
  }
4641
- // Get final continuation message
4642
- const continuationProviderMessage = await continuationStreamingResponse.finalMessage;
4643
4672
  // Validate continuation response (API pattern-aware)
4644
4673
  if (!continuationProviderMessage) {
4645
4674
  console.warn(`[Orchestrator Streaming] Continuation response is null/undefined. Stopping loop.`);
@@ -4683,8 +4712,9 @@ export class CortexOrchestrator {
4683
4712
  }
4684
4713
  const finishReason = continuationProviderMessage.choices[0]?.finish_reason;
4685
4714
  if (finishReason === 'length') {
4686
- console.warn(`[Orchestrator Streaming] Chat Completions API hit max tokens. Stopping tool loop.`);
4687
- break;
4715
+ // R153: do NOT leave the loop — the empty-response classifier below sees `truncated` (via lastStopReason)
4716
+ // and continues with the effort backoff; a partial text answer simply ends the turn as before.
4717
+ console.warn(`[Orchestrator Streaming] Chat Completions API hit max tokens (finish_reason=length) — handing to the truncation path.`);
4688
4718
  }
4689
4719
  }
4690
4720
  // Convert continuation response
@@ -4740,6 +4770,7 @@ export class CortexOrchestrator {
4740
4770
  }
4741
4771
  // Update for next iteration
4742
4772
  currentAssistantCanonicalMessage = continuationAssistantCanonicalMessage;
4773
+ lastStopReason = continuationConvertedResponse.stopReason; // R153
4743
4774
  // Check if continuation has more tool uses
4744
4775
  const continuationToolUses = continuationAssistantCanonicalMessage.content
4745
4776
  .filter((block) => block.type === 'tool_use')
@@ -4936,29 +4967,26 @@ export class CortexOrchestrator {
4936
4967
  this.messageHistory.push(synthUserMessage);
4937
4968
  await this.historyStore.appendMessage(this.currentSessionId, synthUserMessage);
4938
4969
  await this.ensureHistoryFitsModel(effectiveModel);
4939
- const synthCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
4940
- const synthRequest = this.gatewayTranslation.prepareRequest(synthCanonicalHistory, [], // tools suppressed — must produce text
4941
- effectiveModel, {
4942
- temperature: options.parameters?.temperature,
4943
- maxTokens: options.parameters?.maxTokens,
4944
- topP: options.parameters?.topP,
4945
- reasoningEffort: options.parameters?.reasoningEffort,
4946
- stream: false,
4947
- staticSystemPrompt: this.currentStaticSystemPrompt, // R28
4948
- conversationId: this.currentConversationId, // R28b
4949
- });
4950
- if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
4951
- synthRequest.previousResponseId = this.lastResponseId;
4952
- }
4953
- synthRequest.conversationId = this.currentSessionId;
4954
- let synthApiResponse;
4955
- if (this.retryMiddleware) {
4956
- const synthResult = await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(synthRequest, effectiveModel), 'r29a_stream_synthesis_retry');
4957
- synthApiResponse = synthResult.result;
4958
- }
4959
- else {
4960
- synthApiResponse = await this.apiClient.sendRequest(synthRequest, effectiveModel);
4961
- }
4970
+ const synthEffort = this.consumeReasoningBackoff() ?? options.parameters?.reasoningEffort; // R153 backoff > request param
4971
+ const buildSynthRequest = () => {
4972
+ const synthCanonicalHistory = this.convertToCanonicalMessages([...this.messageHistory]);
4973
+ const req = this.gatewayTranslation.prepareRequest(synthCanonicalHistory, [], // tools suppressed — must produce text
4974
+ effectiveModel, {
4975
+ temperature: options.parameters?.temperature,
4976
+ maxTokens: options.parameters?.maxTokens,
4977
+ topP: options.parameters?.topP,
4978
+ reasoningEffort: synthEffort,
4979
+ stream: false,
4980
+ staticSystemPrompt: this.currentStaticSystemPrompt, // R28
4981
+ conversationId: this.currentConversationId, // R28b
4982
+ });
4983
+ if (this.lastResponseId && effectiveModel.api.pattern === 'responses') {
4984
+ req.previousResponseId = this.lastResponseId;
4985
+ }
4986
+ req.conversationId = this.currentSessionId;
4987
+ return req;
4988
+ };
4989
+ const synthApiResponse = await this.sendWithImageHeal(buildSynthRequest, effectiveModel, 'r29a_stream_synthesis_retry');
4962
4990
  const synthConverted = this.gatewayTranslation.convertResponse(synthApiResponse.data, effectiveModel, {
4963
4991
  sessionId: this.currentSessionId,
4964
4992
  conversationId: this.currentConversationId,
@@ -4998,6 +5026,7 @@ export class CortexOrchestrator {
4998
5026
  await this.historyStore.appendMessage(this.currentSessionId, synthAssistantMessage);
4999
5027
  this.sessionTimeline.recordMessage(synthAssistantMessage.uuid, 'assistant');
5000
5028
  currentAssistantCanonicalMessage = synthCanonical;
5029
+ lastStopReason = synthConverted.stopReason; // R153
5001
5030
  if (synthConverted.usage) {
5002
5031
  this.cacheMetricsAccumulator.addUsage(synthConverted.usage, effectiveModel.provider);
5003
5032
  this.noteRequestUsage(synthConverted.usage); // R132
@@ -7393,6 +7422,10 @@ export class CortexOrchestrator {
7393
7422
  // callbacks set after listener setup still work
7394
7423
  const getCallback = () => this.config.onSubAgentEvent;
7395
7424
  // Emit event via callback or console.log
7425
+ // 2026-09-16 (tb4-flash-v3 vllm-deepseek-streaming, 500+ ticker lines): the child heartbeats `progress` once a
7426
+ // SECOND; the console fallback used to print every beat. Print a beat only when the turn number changes or
7427
+ // 30 s have passed since the last printed one (the callback path is untouched — UIs want every beat).
7428
+ const progressLog = { turn: -1, at: 0 };
7396
7429
  const emitEvent = (event) => {
7397
7430
  const callback = getCallback();
7398
7431
  if (callback) {
@@ -7404,9 +7437,16 @@ export class CortexOrchestrator {
7404
7437
  case 'started':
7405
7438
  console.log(`\n${prefix} Started (model: ${event.data.model})`);
7406
7439
  break;
7407
- case 'progress':
7408
- console.log(`${prefix} Turn ${event.data.turnNumber} (${(event.data.elapsedMs / 1000).toFixed(1)}s, ${event.data.totalTokens} tokens)`);
7440
+ case 'progress': {
7441
+ const turn = event.data.turnNumber ?? -1;
7442
+ const now = Date.now();
7443
+ if (turn !== progressLog.turn || now - progressLog.at >= 30_000) {
7444
+ progressLog.turn = turn;
7445
+ progressLog.at = now;
7446
+ console.log(`${prefix} Turn ${event.data.turnNumber} (${(event.data.elapsedMs / 1000).toFixed(1)}s, ${event.data.totalTokens} tokens)`);
7447
+ }
7409
7448
  break;
7449
+ }
7410
7450
  case 'tool_call':
7411
7451
  console.log(`${prefix} Using: ${event.data.toolName}${event.data.toolSummary || ''}`);
7412
7452
  break;
@@ -8166,6 +8206,61 @@ export class CortexOrchestrator {
8166
8206
  this.effortPulseRemaining -= 1;
8167
8207
  return (process.env.CORTEX_EFFORT_PULSE_LEVEL || 'high');
8168
8208
  }
8209
+ /** R153 HB-REASONING-EXHAUSTION: continuations left at a LOWERED reasoning effort after a truncated
8210
+ * reasoning-only turn (output cap hit, nothing delivered). Takes precedence over the effort pulse. */
8211
+ reasoningBackoffRemaining = 0;
8212
+ reasoningBackoffLevel;
8213
+ consumeReasoningBackoff() {
8214
+ if (this.reasoningBackoffRemaining <= 0)
8215
+ return undefined;
8216
+ this.reasoningBackoffRemaining -= 1;
8217
+ return this.reasoningBackoffLevel;
8218
+ }
8219
+ /** Arms the backoff when the empty turn is a reasoning exhaustion; returns the level the next calls will use. */
8220
+ armReasoningBackoffIfExhausted(cls, effectiveEffort, iteration, outputTokens) {
8221
+ if (!isReasoningExhaustion(cls))
8222
+ return undefined;
8223
+ const cfg = resolveReasoningExhaustBackoff();
8224
+ const store = this.getDecisionStore();
8225
+ if (!cfg.enabled) {
8226
+ console.warn(`[Orchestrator] R153: reasoning exhaustion at iteration ${iteration} (output cap hit, nothing delivered) — backoff disabled (CORTEX_REASONING_EXHAUST_BACKOFF=false).`);
8227
+ if (store)
8228
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'reasoning_exhaustion', detail: { iteration, outputTokens, backoff: false } }).catch(() => { });
8229
+ return undefined;
8230
+ }
8231
+ // A repeat exhaustion while a backoff is already armed steps down AGAIN (high → medium → low).
8232
+ const base = this.reasoningBackoffRemaining > 0 && this.reasoningBackoffLevel ? this.reasoningBackoffLevel : effectiveEffort;
8233
+ const level = stepDownEffort(base);
8234
+ this.reasoningBackoffLevel = level;
8235
+ this.reasoningBackoffRemaining = cfg.turns;
8236
+ 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'}').`);
8237
+ if (store)
8238
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'reasoning_exhaustion', detail: { iteration, outputTokens, from: base ?? null, level, turns: cfg.turns } }).catch(() => { });
8239
+ return level;
8240
+ }
8241
+ /** R154 HB-IMAGE-REJECTION-HEAL: send a request built from messageHistory; if the provider rejects an image
8242
+ * block (4xx "unsupported image"), stub every user-side image block and retry the SAME build once. */
8243
+ async sendWithImageHeal(build, model, label) {
8244
+ const send = async (req) => this.retryMiddleware
8245
+ ? (await this.retryMiddleware.executeWithRetry(() => this.apiClient.sendRequest(req, model), label)).result
8246
+ : this.apiClient.sendRequest(req, model);
8247
+ try {
8248
+ return await send(build());
8249
+ }
8250
+ catch (err) {
8251
+ if (!isImageRejectionError(err))
8252
+ throw err;
8253
+ const reason = String(err?.message ?? err).slice(0, 300);
8254
+ const stripped = stripRejectedImages(this.messageHistory, reason);
8255
+ if (stripped === 0)
8256
+ throw err;
8257
+ console.warn(`[Orchestrator] R154: provider rejected an image block on ${label} — stubbed ${stripped} image block(s) in history and retrying once.`);
8258
+ const store = this.getDecisionStore();
8259
+ if (store)
8260
+ void store.recordEvent({ sessionId: this.currentSessionId ?? 'unknown', kind: 'image_rejection_heal', detail: { stripped, label, reason: reason.slice(0, 160) } }).catch(() => { });
8261
+ return await send(build());
8262
+ }
8263
+ }
8169
8264
  /** Honored AskForAdvice consults per session (rate-limit + rung state; persists across turns). */
8170
8265
  mentorConsultCounts = new Map();
8171
8266
  /**