@nexus-cortex/core 4.116.2 → 4.117.1

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 (38) hide show
  1. package/.env.defaults +43 -0
  2. package/dist/config/RuntimeConfigRegistry.d.ts.map +1 -1
  3. package/dist/config/RuntimeConfigRegistry.js +6 -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 +6 -0
  7. package/dist/config/SettingsLoader.js.map +1 -1
  8. package/dist/config/SettingsSchema.d.ts +6 -0
  9. package/dist/config/SettingsSchema.d.ts.map +1 -1
  10. package/dist/config/SettingsSchema.js +54 -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 +6 -0
  14. package/dist/config/effectiveConfig.js.map +1 -1
  15. package/dist/middleware/HelperModelMiddleware.d.ts +3 -0
  16. package/dist/middleware/HelperModelMiddleware.d.ts.map +1 -1
  17. package/dist/middleware/HelperModelMiddleware.js +7 -7
  18. package/dist/middleware/HelperModelMiddleware.js.map +1 -1
  19. package/dist/orchestrator/CortexOrchestrator.d.ts +6 -0
  20. package/dist/orchestrator/CortexOrchestrator.d.ts.map +1 -1
  21. package/dist/orchestrator/CortexOrchestrator.js +141 -9
  22. package/dist/orchestrator/CortexOrchestrator.js.map +1 -1
  23. package/dist/orchestrator/turnContractValidator.d.ts +25 -0
  24. package/dist/orchestrator/turnContractValidator.d.ts.map +1 -0
  25. package/dist/orchestrator/turnContractValidator.js +41 -0
  26. package/dist/orchestrator/turnContractValidator.js.map +1 -0
  27. package/dist/training/DecisionStore.d.ts +1 -1
  28. package/dist/training/DecisionStore.d.ts.map +1 -1
  29. package/dist/training/DecisionStore.js.map +1 -1
  30. package/dist/training/endTurnResolver.d.ts +53 -5
  31. package/dist/training/endTurnResolver.d.ts.map +1 -1
  32. package/dist/training/endTurnResolver.js +99 -23
  33. package/dist/training/endTurnResolver.js.map +1 -1
  34. package/dist/training/judgeEvidence.d.ts +6 -0
  35. package/dist/training/judgeEvidence.d.ts.map +1 -1
  36. package/dist/training/judgeEvidence.js +67 -2
  37. package/dist/training/judgeEvidence.js.map +1 -1
  38. package/package.json +3 -3
@@ -19,7 +19,7 @@ import { join as pathJoin } from 'path';
19
19
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
20
20
  import { execSync } from 'node:child_process';
21
21
  import { ENV_RECON_COMMAND, resolveLiftPlanConfig } from '../training/liftPlanner.js';
22
- import { resolveEndTurnResolverConfig, parseResolverVerdict, effectiveMaxRejects, budgetedVetoEscalation, decideVetoAction, shouldFinishConfirm, buildFinishConfirmMessage } from '../training/endTurnResolver.js';
22
+ import { resolveEndTurnResolverConfig, parseResolverVerdict, effectiveMaxRejects, budgetedVetoEscalation, decideVetoAction, shouldFinishConfirm, buildFinishConfirmMessage, buildMeetsConfirmMessage } from '../training/endTurnResolver.js';
23
23
  import { resolveDeadlineExitConfig, deadlineExitCallBudget, parseDeadlineExitVerdict } from '../training/deadlineExitMentor.js';
24
24
  import { isTaskShaped } from './requirementsVerification.js';
25
25
  import { extractServerSideMetadata, XAIServerSideTools, OpenAIServerSideTools, toCanonicalTool } from '../tools/ServerSideTools.js';
@@ -41,7 +41,7 @@ import { TurnEvidence, evaluateEndTurnGates, buildMissingEndTurnReminder } from
41
41
  import { resolveCompactionResume, pickDropped, buildCompactionResumeReminder, isCompactionResumeMessage, resolveTaskText, resumeMemoryTargetTokens, coversAll, buildRollingSeedMessage, memoryIndexLine, upsertMemoryIndex, scaleEstimateToRequestView, approxCharsOf, anchoredRequestEstimate, resolveCompactionHandoffQA, resolveHandoffQAMaxQuestions, runHandoffQA } from './compactionResume.js';
42
42
  import { renderResumeMemoryPrompt, buildRebuildInstructions, resolveCheckpointBand, resolveRearmBand } from './compactionResumeTemplate.js';
43
43
  import { resolveCortexStateDir, cortexStatePath } from '../utils/stateDir.js';
44
- import { collectWorkspaceDelta, detectCheckCommand, runCheck, resolveJudgeGroundingConfig, classifyCheckRun } from '../training/judgeEvidence.js';
44
+ import { collectWorkspaceDelta, detectCheckCommand, runCheck, resolveJudgeGroundingConfig, classifyCheckRun, isInvestigateCommandAllowed, readFileSlice, formatEvidenceRound } from '../training/judgeEvidence.js';
45
45
  import { resolveMentorRoleConfig, describeMentorWire, describeMentorDelivery, mentorSurfaceTimeoutMs } from '../training/mentorRole.js';
46
46
  import { resolveOuterToolDeadlineMs, resolveOuterToolTimeoutFloorMs } from './outerToolTimeout.js';
47
47
  import { resolveSubAgentTimeoutMs } from './subAgentTimeout.js';
@@ -72,6 +72,7 @@ import { closestToolMatches } from './toolNameMatcher.js';
72
72
  import { classifyApiError } from './apiErrorClassifier.js';
73
73
  import { pinStaticSystemPrompt } from './staticSystemPromptPin.js';
74
74
  import { hasVisibleAssistantText, shouldForceSynthesis } from './assistantTextPresence.js';
75
+ import { resolveTurnContractEnforce, checkTurnFormat, buildFormatRejectMessage } from './turnContractValidator.js'; // HB-TURN-CONTRACT-ENFORCE
75
76
  import { computeToolBudgetSignal, isToolProgressStalled } from './toolBudgetSignal.js';
76
77
  import { createStructuredOutputTurnState, ensureStructuredOutputTool, evaluateStructuredOutputCall, finalizeStructuredOutput, isStructuredOutputToolName, } from './structuredOutput.js';
77
78
  // Phase 2.6: MCP Model Management Tools
@@ -231,6 +232,9 @@ export class CortexOrchestrator {
231
232
  judgePriorNamedPassed = 0; // R166a: named checks passing at the last veto
232
233
  finishConfirms = 0; // R167: informed finish confirmations issued this turn
233
234
  judgeAcceptedWithGap = false; // R167: the judge accepted this finish WITH a gap → regex nudges may still fire
235
+ judgeMeetsUnevidenced = false; // R168: the judge said MEETS but no proving check passed → regex nudges may still fire
236
+ turnFormatRejects = 0; // HB-TURN-CONTRACT-ENFORCE: format rejections issued this turn
237
+ pendingFormatReject = null; // set by the loop, consumed by handleToolCalls (all three execution paths)
234
238
  judgeNamedChecks = [];
235
239
  judgePriorPlan = '';
236
240
  judgeEscalated = false;
@@ -594,6 +598,38 @@ export class CortexOrchestrator {
594
598
  * Bounded by CORTEX_ENDTURN_RESOLVER_MAX_REJECTS then fallback-accepts (liveness). No-op when the resolver
595
599
  * is unarmed → byte-identical. Fail-open on any error → leaves the finish accepted.
596
600
  */
601
+ /** HB-TURN-CONTRACT-ENFORCE: decide whether this tool-calling response is rejected on format (ANALYSIS + PLAN missing).
602
+ * Sets `pendingFormatReject` for handleToolCalls; bounded per turn; finish batches (EndTurn) exempt; events banked. */
603
+ armTurnFormatReject(content, toolUseBlocks, iteration, streaming) {
604
+ this.pendingFormatReject = null;
605
+ if (toolUseBlocks.length === 0)
606
+ return;
607
+ const tc = resolveTurnContractEnforce(process.env);
608
+ if (!tc.enforce)
609
+ return;
610
+ if (toolUseBlocks.some((t) => t.name === 'EndTurn'))
611
+ return; // the finish path has its own gates + judge
612
+ const text = (Array.isArray(content) ? content : []).filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n');
613
+ const fmt = checkTurnFormat(text);
614
+ if (fmt.ok)
615
+ return;
616
+ const store = this.getDecisionStore();
617
+ const sessionId = this.currentSessionId ?? 'unknown';
618
+ if (this.turnFormatRejects >= tc.max) {
619
+ if (this.turnFormatRejects === tc.max) { // bank the fallback once per turn
620
+ this.turnFormatRejects += 1;
621
+ console.warn(`[TurnContract] format still missing ${fmt.missing.join('+')} after ${tc.max} rejection(s) — executing the batch as-is (fallback)`);
622
+ if (store)
623
+ void store.recordEvent({ sessionId, kind: 'turn_contract_fallback', detail: { iteration, rejects: tc.max, missing: fmt.missing, tools: toolUseBlocks.map((t) => t.name).slice(0, 8), streaming } }).catch(() => { });
624
+ }
625
+ return;
626
+ }
627
+ this.turnFormatRejects += 1;
628
+ this.pendingFormatReject = { missing: fmt.missing, index: this.turnFormatRejects, max: tc.max };
629
+ console.warn(`[TurnContract] REJECTED response format (missing ${fmt.missing.join('+')}; ${toolUseBlocks.length} tool call(s) not executed; rejection ${this.turnFormatRejects}/${tc.max})`);
630
+ if (store)
631
+ void store.recordEvent({ sessionId, kind: 'turn_contract_reject', detail: { iteration, rejects: this.turnFormatRejects, max: tc.max, missing: fmt.missing, tools: toolUseBlocks.map((t) => t.name).slice(0, 8), textChars: text.length, streaming } }).catch(() => { });
632
+ }
597
633
  async adjudicateEndTurn(ev, toolResults, toolUses, model) {
598
634
  if (!resolveEndTurnResolver(process.env, model.endTurnResolver))
599
635
  return;
@@ -609,6 +645,7 @@ export class CortexOrchestrator {
609
645
  return;
610
646
  this.judgeAdjudicatedThisFinish = false;
611
647
  this.judgeAcceptedWithGap = false; // R167
648
+ this.judgeMeetsUnevidenced = false; // R168
612
649
  // R160 HB-RESOLVER-BUDGET-CAP: the cap is budget-aware — while >= CORTEX_BUDGET_CONTINUE_MIN_REMAINING of the wall
613
650
  // budget remains the judge may veto up to maxRejectsBudgeted times; below that (or with no deadline) the liveness cap.
614
651
  const resolverRemainingMs = this.turnDeadlineMsActive > 0 && this.turnLoopStartMs > 0 ? Math.max(0, this.turnDeadlineMsActive - (Date.now() - this.turnLoopStartMs)) : null;
@@ -672,7 +709,8 @@ export class CortexOrchestrator {
672
709
  : undefined;
673
710
  const timeoutMs = mentorSurfaceTimeoutMs('endturn-resolver', parseInt(process.env.CORTEX_ENDTURN_RESOLVER_TIMEOUT_MS ?? '90000', 10)); // thinking-aware
674
711
  const t0 = Date.now();
675
- const judgeOnce = (reasoning) => withTimeout(this.helperMiddleware.evaluateEndTurn({
712
+ const evidenceRounds = []; // R170
713
+ const judgeOnce = (reasoning, investigate) => withTimeout(this.helperMiddleware.evaluateEndTurn({
676
714
  task,
677
715
  liftPlan: this.liftPlanText || undefined,
678
716
  envReport: this.gatherEnvReport({ fresh: true }),
@@ -683,15 +721,71 @@ export class CortexOrchestrator {
683
721
  priorVetoItems,
684
722
  progressSummary,
685
723
  reasoning,
724
+ evidenceRounds: evidenceRounds.length ? evidenceRounds.slice() : undefined, // R170
725
+ investigate,
686
726
  helperModelId: this.config.reactiveMentorship?.helperModelId,
687
727
  }), reasoning === 'on' ? mentorSurfaceTimeoutMs('endturn-resolver', Math.max(timeoutMs, 180000)) : timeoutMs);
688
- let text = await judgeOnce();
689
- let verdict = parseResolverVerdict(text ?? ''); // withTimeout → null on timeout; blank → ABSTAIN (below)
728
+ // R170 HB-JUDGE-TOOL-LOOP: up to cfg.toolRounds calls; while rounds and the aggregate budget remain the judge may
729
+ // INVESTIGATE (checks run via the existing runCheck surface behind a read-only denylist; file slices read in-workspace),
730
+ // each round's EVIDENCE block rides on the next prompt; the last round withdraws the option. toolRounds=1 = one call.
731
+ let text = null;
732
+ let verdict = parseResolverVerdict('');
733
+ let roundsUsed = 0;
734
+ let investigateChecks = 0;
735
+ let investigateReads = 0;
736
+ let investigateRefused = 0;
737
+ const roundLatencyMs = [];
738
+ for (let round = 1; round <= cfg.toolRounds; round++) {
739
+ const loopElapsed = Date.now() - t0;
740
+ const canInvestigate = cfg.toolRounds > 1 && round < cfg.toolRounds && loopElapsed < cfg.toolRoundBudgetMs &&
741
+ (resolverRemainingMs === null || resolverRemainingMs - loopElapsed > 2 * cfg.toolRoundBudgetMs);
742
+ const mode = cfg.toolRounds > 1 ? (canInvestigate ? 'offer' : 'withdraw') : undefined;
743
+ const r0 = Date.now();
744
+ text = await judgeOnce(undefined, mode);
745
+ roundLatencyMs.push(Date.now() - r0);
746
+ roundsUsed = round;
747
+ verdict = parseResolverVerdict(text ?? ''); // withTimeout → null on timeout; blank → ABSTAIN (below)
748
+ if (!verdict.investigate)
749
+ break;
750
+ if (!canInvestigate) {
751
+ verdict = { ...verdict, blank: true, parsed: false };
752
+ break;
753
+ } // asked after withdrawal → abstain, never re-loop
754
+ const checkResults = [];
755
+ const readResults = [];
756
+ for (const c of verdict.checks.slice(0, 3)) {
757
+ if (!isInvestigateCommandAllowed(c)) {
758
+ investigateRefused += 1;
759
+ checkResults.push(`CHECK RUN: \`${c}\` → REFUSED (investigation checks must be read-only)`);
760
+ continue;
761
+ }
762
+ const r = runCheck(judgeCwd, c, jcfg);
763
+ investigateChecks += 1;
764
+ namedRan += 1;
765
+ {
766
+ const k = classifyCheckRun(r);
767
+ if (k === 'passed')
768
+ namedPassed += 1;
769
+ else if (k === 'failed')
770
+ namedFailed += 1;
771
+ else
772
+ namedInconclusive += 1;
773
+ }
774
+ checkResults.push(r);
775
+ }
776
+ for (const p of verdict.reads.slice(0, 3)) {
777
+ readResults.push(readFileSlice(judgeCwd, p));
778
+ investigateReads += 1;
779
+ }
780
+ evidenceRounds.push(formatEvidenceRound(round, checkResults, readResults));
781
+ console.warn(`[EndTurnResolver] R170 INVESTIGATE round ${round}/${cfg.toolRounds} — ${checkResults.length} check(s), ${readResults.length} read(s)`);
782
+ }
690
783
  // R166 HB-JUDGE-EVIDENCE-VETO: in evidence mode the checks THIS verdict names run now — a GAP without a failing
691
784
  // command is an opinion, and opinion no longer holds a finish (18 of 19 grader-accepted solutions were vetoed first).
692
785
  let namedRanNow = 0;
693
786
  let namedResultsNow = '';
694
- if (cfg.semantic && cfg.vetoMode === 'evidence' && !verdict.meets && !verdict.blank && verdict.checks.length > 0) {
787
+ // R168: with meetsConfirm the checks a MEETS names run too — their passing is what lets the MEETS stand.
788
+ if (cfg.semantic && cfg.vetoMode === 'evidence' && (!verdict.meets || cfg.meetsConfirm) && !verdict.blank && !verdict.investigate && verdict.checks.length > 0) {
695
789
  for (const c of verdict.checks.slice(0, 3)) {
696
790
  const r = runCheck(judgeCwd, c, jcfg);
697
791
  namedRanNow += 1;
@@ -714,7 +808,7 @@ export class CortexOrchestrator {
714
808
  if (action === 'escalate') {
715
809
  // R165: the junior re-attested without working the plan — one thinking-on adjudication before we accept with the gap recorded.
716
810
  this.judgeEscalated = true;
717
- if (cfg.escalateReasoning) {
811
+ if (cfg.escalateReasoning && roundsUsed <= 1) { // R170: a multi-round adjudication already spent the extra reasoning
718
812
  console.warn(`[EndTurnResolver] R165: re-attest without progress (${callsSince} calls since the veto) — escalating the judge to reasoning-on once.`);
719
813
  const text2 = await judgeOnce('on');
720
814
  const v2 = parseResolverVerdict(text2 ?? '');
@@ -745,6 +839,7 @@ export class CortexOrchestrator {
745
839
  cap: resolverCap, capLiveness: cfg.maxRejects, capBudgeted: cfg.maxRejectsBudgeted, remainingFrac: resolverRemainingFrac === null ? null : Number(resolverRemainingFrac.toFixed(3)), // R160
746
840
  semantic: cfg.semantic, action, confidence: verdict.confidence, namedChecks: verdict.checks.length, namedRan, namedPassed, namedFailed, callsSince, progressed, escalated: this.judgeEscalated, // R165
747
841
  vetoMode: cfg.vetoMode, evidenceCap: cfg.evidenceCap, namedRanNow, namedInconclusive, // R166 / R166b
842
+ toolRounds: cfg.toolRounds, roundsUsed, investigateChecks, investigateReads, investigateRefused, roundLatencyMs, evidenceChars: evidenceRounds.join('').length, // R170
748
843
  latencyMs, rawLen: (text ?? '').length,
749
844
  deltaChars: workspaceDelta.length, checkRan: !!checkResult, checkPassed: checkResult ? /→ PASSED/.test(checkResult) : null,
750
845
  liftPlanChars: this.liftPlanText.length,
@@ -759,7 +854,7 @@ export class CortexOrchestrator {
759
854
  // content / no verdict line / timeout). That is a judge FAILURE, not a judgment — the finish stands, no
760
855
  // veto, no reject increment, and the event says so (abstained + blank + failOpen) instead of counting as MEETS.
761
856
  if (this.config.debug)
762
- console.warn(`[EndTurnResolver] BLANK — abstained (no verdict from the judge, rawLen=${(text ?? '').length})`);
857
+ console.warn(`[EndTurnResolver] BLANK — abstained (no verdict from the judge, rawLen=${(text ?? '').length}${verdict.investigate ? ', INVESTIGATE after withdrawal' : ''})`);
763
858
  }
764
859
  else if (verdict.retire && cfg.abstain) {
765
860
  // ABSTENTION — the finish is structurally unclosable; accept it and STOP the reject
@@ -809,6 +904,28 @@ export class CortexOrchestrator {
809
904
  if (store)
810
905
  void store.recordEvent({ sessionId, kind: 'endturn_gap_accepted', toolName: 'EndTurn', detail: { action, rejects: this.endTurnResolverRejects, cap: resolverCap, confidence: verdict.confidence, plan: verdict.plan.slice(0, 2000), checks: verdict.checks, callsSince, escalated: this.judgeEscalated } }).catch(() => { });
811
906
  }
907
+ else if (action === 'accept' && verdict.meets) {
908
+ // R168 HB-MEETS-CONFIRM: a MEETS stands only when a proving check passed. Otherwise, with budget left, hold ONCE
909
+ // (shared counter with R167) showing what was (not) verified; the next EndTurn stands.
910
+ const evidencePassed = namedPassed > 0 || (!!checkResult && /→ PASSED/.test(checkResult));
911
+ if (cfg.meetsConfirm && !evidencePassed)
912
+ this.judgeMeetsUnevidenced = true; // let the budget-continue / surrender nudges see this finish
913
+ if (shouldFinishConfirm({ action, remainingFrac: resolverRemainingFrac, confirmsUsed: this.finishConfirms, cfg, meets: true, evidencePassed })) {
914
+ this.finishConfirms += 1;
915
+ ev.endTurnCalled = false;
916
+ et.is_error = true;
917
+ et.content = buildMeetsConfirmMessage({
918
+ remainingFrac: resolverRemainingFrac ?? 0, remainingMs: resolverRemainingMs ?? 0,
919
+ checksNamed: verdict.checks.length, checkResults: namedResultsNow, openItems: etInput.open_items,
920
+ });
921
+ console.warn(`[EndTurnResolver] R168 MEETS-CONFIRM — unverified MEETS held once (checks named ${verdict.checks.length}, passed ${namedPassed}, budget remaining ${Math.round((resolverRemainingFrac ?? 0) * 100)}%)`);
922
+ if (store)
923
+ void store.recordEvent({ sessionId, kind: 'finish_confirm', toolName: 'EndTurn', detail: { action, meets: true, evidencePassed, checksNamed: verdict.checks.length, namedRanNow, namedPassed, namedFailed, namedInconclusive, remainingFrac: resolverRemainingFrac, confirms: this.finishConfirms, planChars: verdict.plan.length, openItems: Array.isArray(etInput.open_items) ? etInput.open_items.length : 0 } }).catch(() => { });
924
+ }
925
+ else if (this.config.debug) {
926
+ console.log(`[EndTurnResolver] MEETS — finish confirmed (parsed=${verdict.parsed}, evidencePassed=${evidencePassed})`);
927
+ }
928
+ }
812
929
  else if (this.config.debug) {
813
930
  console.log(`[EndTurnResolver] MEETS — finish confirmed (parsed=${verdict.parsed})`);
814
931
  }
@@ -1730,6 +1847,9 @@ export class CortexOrchestrator {
1730
1847
  this.judgePriorNamedPassed = 0;
1731
1848
  this.finishConfirms = 0;
1732
1849
  this.judgeAcceptedWithGap = false;
1850
+ this.judgeMeetsUnevidenced = false;
1851
+ this.turnFormatRejects = 0;
1852
+ this.pendingFormatReject = null;
1733
1853
  this.judgeNamedChecks = [];
1734
1854
  this.judgePriorPlan = '';
1735
1855
  this.judgeEscalated = false;
@@ -1879,6 +1999,7 @@ export class CortexOrchestrator {
1879
1999
  if (this.config.debug && toolUseBlocks.length > 0) {
1880
2000
  console.log(`[Orchestrator] Extracted tools: ${toolUseBlocks.map((t) => t.name).join(', ')}`);
1881
2001
  }
2002
+ this.armTurnFormatReject(currentAssistantCanonicalMessage.content, toolUseBlocks, toolCallIteration, false); // HB-TURN-CONTRACT-ENFORCE
1882
2003
  // If no tool calls, we're done — UNLESS the response is empty.
1883
2004
  if (toolUseBlocks.length === 0) {
1884
2005
  // Round 18b (multi-model bench finding): detect empty responses
@@ -2203,7 +2324,7 @@ export class CortexOrchestrator {
2203
2324
  .join('\n')
2204
2325
  : String(currentAssistantCanonicalMessage.content ?? '');
2205
2326
  // R165: when the semantic judge adjudicated this finish, the regex surrender/open-items nudges defer to it.
2206
- const judgeOwnsFinish = this.judgeAdjudicatedThisFinish && resolveEndTurnResolverConfig().semantic && !this.judgeAcceptedWithGap; // R167: an accept-with-gap leaves the regex nudges live
2327
+ const judgeOwnsFinish = this.judgeAdjudicatedThisFinish && resolveEndTurnResolverConfig().semantic && !this.judgeAcceptedWithGap && !this.judgeMeetsUnevidenced; // R167: an accept-with-gap leaves the regex nudges live; R168: so does an unverified MEETS
2207
2328
  const surrenderUnsatisfied = resolveSurrenderNudgeMode() && ev.usedTools && !surrenderNudgeUsed && !judgeOwnsFinish &&
2208
2329
  detectSurrenderText(surrenderDraftText);
2209
2330
  // R151 HB-BUDGET-VISIBILITY: an open-items finish with >= MIN_REMAINING of the wall budget left gets ONE
@@ -3926,6 +4047,9 @@ export class CortexOrchestrator {
3926
4047
  this.judgePriorNamedPassed = 0;
3927
4048
  this.finishConfirms = 0;
3928
4049
  this.judgeAcceptedWithGap = false;
4050
+ this.judgeMeetsUnevidenced = false;
4051
+ this.turnFormatRejects = 0;
4052
+ this.pendingFormatReject = null;
3929
4053
  this.judgeNamedChecks = [];
3930
4054
  this.judgePriorPlan = '';
3931
4055
  this.judgeEscalated = false;
@@ -4068,6 +4192,7 @@ export class CortexOrchestrator {
4068
4192
  input: block.toolUse.input
4069
4193
  }));
4070
4194
  ev.noteToolUses(toolUseBlocks);
4195
+ this.armTurnFormatReject(currentAssistantCanonicalMessage.content, toolUseBlocks, toolCallIteration, true); // HB-TURN-CONTRACT-ENFORCE
4071
4196
  if (toolUseBlocks.length === 0 && !gateReRequest) {
4072
4197
  // R32 (streaming R18b parity): detect empty/thinking-only responses.
4073
4198
  // sendMessage retries ONCE inside the loop with tools preserved.
@@ -6602,6 +6727,13 @@ export class CortexOrchestrator {
6602
6727
  }).catch(() => { });
6603
6728
  }
6604
6729
  async handleToolCalls(toolUseBlocks, signal, structuredOutputState) {
6730
+ // HB-TURN-CONTRACT-ENFORCE: a batch the loop rejected on format carries the re-prompt back as error results; nothing runs.
6731
+ if (this.pendingFormatReject) {
6732
+ const rej = this.pendingFormatReject;
6733
+ this.pendingFormatReject = null;
6734
+ const msg = buildFormatRejectMessage(rej.missing, rej.index, rej.max, toolUseBlocks.map((t) => t.name));
6735
+ return toolUseBlocks.map((t) => ({ tool_use_id: t.id, tool_name: t.name, content: msg, is_error: true, metadata: { formatRejected: true } }));
6736
+ }
6605
6737
  // StructuredOutput (grok-build port): intercept BEFORE any dispatch — the
6606
6738
  // synthetic tool is request-scoped (never in a registry) and must NEVER
6607
6739
  // reach a real executor. Each call gets a synthesized tool_result: